Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0d6d836a7 | |||
| 8b3c1f57bc | |||
| 139ea7226d | |||
| 69858b5a16 |
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/va-h/devcontainers-features/uv:1": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/copilot-cli:1": {}
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.8": {}
|
||||
},
|
||||
"postCreateCommand": "bash ./devsetup.sh",
|
||||
"workspaceFolder": "/workspaces/agent-framework/python/",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"GitHub.vscode-github-actions",
|
||||
"ms-python.python",
|
||||
"ms-windows-ai-studio.windows-ai-studio",
|
||||
"littlefoxteam.vscode-python-test-adapter"
|
||||
|
||||
@@ -2,14 +2,13 @@ dirs:
|
||||
- .
|
||||
excludedFiles:
|
||||
- ./python/CHANGELOG.md
|
||||
- "**/SKILL.md"
|
||||
ignorePatterns:
|
||||
- pattern: "/github/"
|
||||
- pattern: "./actions"
|
||||
- pattern: "./blob"
|
||||
- pattern: "./issues"
|
||||
- pattern: "./discussions"
|
||||
- pattern: "./pull"
|
||||
- pattern: "./pulls"
|
||||
- pattern: "https:\/\/platform.openai.com"
|
||||
- pattern: "http:\/\/localhost"
|
||||
- pattern: "http:\/\/127.0.0.1"
|
||||
@@ -21,13 +20,10 @@ ignorePatterns:
|
||||
- pattern: "https://your-resource.openai.azure.com/"
|
||||
- pattern: "http://host.docker.internal"
|
||||
- pattern: "https://openai.github.io/openai-agents-js/openai/agents/classes/"
|
||||
# dotnet.microsoft.com bot-blocks CI link checkers with intermittent 403s on any
|
||||
# path (including localized variants like /en-us/download/...), so ignore the
|
||||
# whole domain rather than just /download.
|
||||
- pattern: "https:\/\/dotnet.microsoft.com"
|
||||
- pattern: "https:\/\/dotnet.microsoft.com\/download"
|
||||
- pattern: "https://github.com/Rel1cx/eslint-react"
|
||||
# excludedDirs:
|
||||
# Folders which include links to localhost, since it's not ignored with regular expressions
|
||||
# Folders which include links to localhost, since it's not ignored with regular expressions
|
||||
baseUrl: https://github.com/microsoft/agent-framework/
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -1,130 +1,7 @@
|
||||
# Code ownership assignments
|
||||
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# Policy: a PR needs one approval, and it must come from a code owner of the changed
|
||||
# files ("Require review from Code Owners" + "Required approvals: 1" on `main`).
|
||||
# A PR touching several CODEOWNERS patterns will request review from the applicable
|
||||
# code owners, but an approval from any applicable code owner is sufficient to satisfy
|
||||
# GitHub's required-code-owner review.
|
||||
#
|
||||
# Order matters: the LAST matching pattern wins, so a module rule fully replaces the
|
||||
# catch-all rather than adding to it. @chetantoshniwal is included on every line as a
|
||||
# repository-wide fallback owner. All owners on a line have equal approval authority.
|
||||
#
|
||||
# CONVENTION: owners are written in the order
|
||||
# @chetantoshniwal <owner A> <owner B> [...]
|
||||
# @chetantoshniwal is at the beginning for aesthetics. The owners share equal approval
|
||||
# power and responsibility.
|
||||
#
|
||||
# RULE: every path must list at least two owners besides @chetantoshniwal. An author
|
||||
# cannot approve their own PR, so a path with a single module owner leaves only Chetan
|
||||
# to review whenever that owner is the author, which defeats the point of naming a
|
||||
# module owner.
|
||||
#
|
||||
# Samples: owned by all core developers of that language, not by the module a sample
|
||||
# demonstrates. Any core Python developer can approve any Python sample, and any core
|
||||
# .NET developer can approve any .NET sample. No dedicated rule is needed -- samples
|
||||
# fall through to the /python and /dotnet rules, which already list those developers.
|
||||
#
|
||||
# Tests: same as samples. Tests that live inside a package (python/packages/<pkg>/tests)
|
||||
# are covered by that package's rule instead, since they sit under its path.
|
||||
|
||||
# Default owners for everything not matched by a module rule below.
|
||||
* @chetantoshniwal @westey-m
|
||||
|
||||
# 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
|
||||
|
||||
# Repository-level paths that require specific owners
|
||||
/.devcontainer @chetantoshniwal @westey-m @rogerbarreto @SergeyMenshykh
|
||||
/declarative-agents @chetantoshniwal @moonbox3 @peibekwe
|
||||
|
||||
# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
|
||||
# Python packages
|
||||
/python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
|
||||
/python/packages/ag-ui/ @chetantoshniwal @moonbox3 @giles17
|
||||
/python/packages/anthropic/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/azure-ai-search/ @chetantoshniwal @eavanvalkenburg @giles17
|
||||
/python/packages/azure-contentunderstanding/ @chetantoshniwal @giles17 @eavanvalkenburg
|
||||
/python/packages/azure-cosmos/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/azure-cosmos-memory/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/bedrock/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/chatkit/ @chetantoshniwal @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 @moonbox3 @TaoChenOSU
|
||||
/python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3
|
||||
/python/packages/declarative/ @chetantoshniwal @moonbox3 @peibekwe
|
||||
/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_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/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
|
||||
/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
|
||||
/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 @moonbox3 @TaoChenOSU
|
||||
/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
|
||||
|
||||
# .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/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.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.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
|
||||
/dotnet/src/Microsoft.Agents.AI.Harness/ @chetantoshniwal @westey-m @SergeyMenshykh
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
|
||||
/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.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
|
||||
python/packages/azurefunctions/ @microsoft/agentframework-durabletask-developers
|
||||
python/packages/durabletask/ @microsoft/agentframework-durabletask-developers
|
||||
python/samples/getting_started/azure_functions/ @microsoft/agentframework-durabletask-developers
|
||||
python/samples/getting_started/durabletask/ @microsoft/agentframework-durabletask-developers
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: .NET Bug Report
|
||||
description: Report a bug in the Agent Framework .NET SDK
|
||||
title: ".NET: [Bug]: "
|
||||
labels: [".NET"]
|
||||
labels: ["bug", ".NET"]
|
||||
type: bug
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Python Bug Report
|
||||
description: Report a bug in the Agent Framework Python SDK
|
||||
title: "Python: [Bug]: "
|
||||
labels: ["Python"]
|
||||
labels: ["bug", "Python"]
|
||||
type: bug
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Azure Functions Integration Test Setup
|
||||
description: Prepare local emulators and tools for Azure Functions integration tests
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Start Durable Task Scheduler Emulator
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$(docker ps -aq -f name=dts-emulator)" ]; then
|
||||
echo "Stopping and removing existing Durable Task Scheduler Emulator"
|
||||
docker rm -f dts-emulator
|
||||
fi
|
||||
echo "Starting Durable Task Scheduler Emulator"
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 -e DTS_USE_DYNAMIC_TASK_HUBS=true mcr.microsoft.com/dts/dts-emulator:latest
|
||||
echo "Waiting for Durable Task Scheduler Emulator to be ready"
|
||||
timeout 30 bash -c 'until curl --silent http://localhost:8080/healthz; do sleep 1; done'
|
||||
echo "Durable Task Scheduler Emulator is ready"
|
||||
- name: Start Azurite (Azure Storage emulator)
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$(docker ps -aq -f name=azurite)" ]; then
|
||||
echo "Stopping and removing existing Azurite (Azure Storage emulator)"
|
||||
docker rm -f azurite
|
||||
fi
|
||||
echo "Starting Azurite (Azure Storage emulator)"
|
||||
docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
|
||||
echo "Waiting for Azurite (Azure Storage emulator) to be ready"
|
||||
timeout 30 bash -c 'until curl --silent http://localhost:10000/devstoreaccount1; do sleep 1; done'
|
||||
echo "Azurite (Azure Storage emulator) is ready"
|
||||
- name: Start Redis
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$(docker ps -aq -f name=redis)" ]; then
|
||||
echo "Stopping and removing existing Redis"
|
||||
docker rm -f redis
|
||||
fi
|
||||
echo "Starting Redis"
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
echo "Waiting for Redis to be ready"
|
||||
timeout 30 bash -c 'until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done'
|
||||
echo "Redis is ready"
|
||||
- name: Install Azure Functions Core Tools
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Installing Azure Functions Core Tools"
|
||||
npm install -g azure-functions-core-tools@4 --unsafe-perm true
|
||||
func --version
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Free runner disk space
|
||||
description: |
|
||||
Reclaims disk space on GitHub-hosted Ubuntu runners by removing
|
||||
pre-installed toolchains we do not use (Android SDK, GHC/Haskell,
|
||||
CodeQL bundle), Docker images, and swap. Also relocates the
|
||||
NuGet package cache to /mnt (which has ~75 GB free vs ~14 GB
|
||||
on /). No-op on non-Linux runners.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Free disk space (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Disk usage before cleanup"
|
||||
df -h /
|
||||
echo "::endgroup::"
|
||||
|
||||
# Remove pre-installed toolchains we never use on this repo's
|
||||
# dotnet/python jobs. These reclaim ~25-30 GB on ubuntu-latest.
|
||||
sudo rm -rf \
|
||||
/usr/local/lib/android \
|
||||
/usr/share/dotnet/sdk/NuGetFallbackFolder \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL \
|
||||
/opt/hostedtoolcache/PyPy \
|
||||
/opt/hostedtoolcache/Ruby \
|
||||
/opt/hostedtoolcache/go \
|
||||
/usr/local/share/boost \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/vcpkg \
|
||||
/usr/local/lib/heroku \
|
||||
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/PyPy" \
|
||||
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/Ruby" \
|
||||
"${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}/go" || true
|
||||
|
||||
# Drop docker images shipped on the runner; jobs that need
|
||||
# docker pull what they need fresh.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
sudo docker image prune --all --force >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Disable swap to free its backing file.
|
||||
sudo swapoff -a || true
|
||||
sudo rm -f /mnt/swapfile /swapfile || true
|
||||
|
||||
echo "::group::Disk usage after cleanup"
|
||||
df -h /
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Relocate NuGet package cache to /mnt (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /mnt/nuget
|
||||
sudo chown -R "$USER":"$USER" /mnt/nuget
|
||||
echo "NUGET_PACKAGES=/mnt/nuget" >> "$GITHUB_ENV"
|
||||
echo "Relocated NuGet package cache to /mnt/nuget"
|
||||
df -h /mnt || true
|
||||
@@ -1,112 +0,0 @@
|
||||
name: Get GitHub automation token
|
||||
description: Creates a GitHub App installation token with a temporary PAT fallback
|
||||
|
||||
inputs:
|
||||
mode:
|
||||
description: Authentication mode (app, app-with-fallback, or pat)
|
||||
required: false
|
||||
default: app-with-fallback
|
||||
azure-client-id:
|
||||
description: Client ID of the Azure workload identity
|
||||
required: false
|
||||
azure-tenant-id:
|
||||
description: Azure tenant ID
|
||||
required: false
|
||||
azure-subscription-id:
|
||||
description: Azure subscription containing the Key Vault
|
||||
required: false
|
||||
key-vault-name:
|
||||
description: Azure Key Vault name
|
||||
required: false
|
||||
key-name:
|
||||
description: Key Vault key used to sign the GitHub App JWT
|
||||
required: false
|
||||
github-app-client-id:
|
||||
description: GitHub App client ID
|
||||
required: false
|
||||
github-app-installation-id:
|
||||
description: GitHub App installation ID
|
||||
required: false
|
||||
repository:
|
||||
description: Repository to include in the installation token
|
||||
required: false
|
||||
fallback-token:
|
||||
description: PAT used temporarily when app authentication is unavailable
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: GitHub App installation token or fallback PAT
|
||||
value: ${{ steps.select-token.outputs.token }}
|
||||
source:
|
||||
description: Selected authentication source
|
||||
value: ${{ steps.select-token.outputs.source }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Validate authentication mode
|
||||
shell: bash
|
||||
env:
|
||||
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
|
||||
run: |
|
||||
if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then
|
||||
echo "::error::Unsupported GitHub authentication mode."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign in to Azure
|
||||
id: azure-login
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
|
||||
continue-on-error: true
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Create GitHub App installation token
|
||||
id: app-token
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
KEY_VAULT_NAME: ${{ inputs.key-vault-name }}
|
||||
KEY_NAME: ${{ inputs.key-name }}
|
||||
GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }}
|
||||
GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }}
|
||||
TARGET_REPOSITORY: ${{ inputs.repository }}
|
||||
run: |
|
||||
token="$(node "$GITHUB_ACTION_PATH/create-token.js")"
|
||||
echo "::add-mask::$token"
|
||||
echo "token=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select authentication token
|
||||
id: select-token
|
||||
shell: bash
|
||||
env:
|
||||
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
FALLBACK_TOKEN: ${{ inputs.fallback-token }}
|
||||
run: |
|
||||
if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then
|
||||
token="$APP_TOKEN"
|
||||
source="app"
|
||||
echo "::notice::GitHub authentication source: app"
|
||||
elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then
|
||||
token="$FALLBACK_TOKEN"
|
||||
source="pat-fallback"
|
||||
echo "::warning::GitHub authentication source: PAT fallback"
|
||||
elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then
|
||||
token="$FALLBACK_TOKEN"
|
||||
source="pat-forced"
|
||||
echo "::warning::GitHub authentication source: PAT (forced rollout mode)"
|
||||
else
|
||||
echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::$token"
|
||||
echo "token=$token" >> "$GITHUB_OUTPUT"
|
||||
echo "source=$source" >> "$GITHUB_OUTPUT"
|
||||
@@ -1,133 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function base64Url(value) {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
function base64ToBase64Url(value) {
|
||||
return Buffer.from(value, 'base64').toString('base64url');
|
||||
}
|
||||
|
||||
function createJwtSigningInput(clientId, nowSeconds) {
|
||||
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const payload = base64Url(JSON.stringify({
|
||||
iat: nowSeconds - 60,
|
||||
exp: nowSeconds + 540,
|
||||
iss: clientId,
|
||||
}));
|
||||
return `${header}.${payload}`;
|
||||
}
|
||||
|
||||
function signJwt(signingInput, config, execute = execFileSync) {
|
||||
const digest = crypto.createHash('sha256').update(signingInput).digest('base64');
|
||||
const signature = execute(
|
||||
'az',
|
||||
[
|
||||
'keyvault', 'key', 'sign',
|
||||
'--subscription', config.azureSubscriptionId,
|
||||
'--vault-name', config.keyVaultName,
|
||||
'--name', config.keyName,
|
||||
'--algorithm', 'RS256',
|
||||
'--digest', digest,
|
||||
'--query', 'signature',
|
||||
'--output', 'tsv',
|
||||
'--only-show-errors',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
|
||||
if (!signature) {
|
||||
throw new Error('Key Vault returned an empty signature.');
|
||||
}
|
||||
|
||||
return `${signingInput}.${base64ToBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
async function createInstallationToken(config, dependencies = {}) {
|
||||
const execute = dependencies.execute ?? execFileSync;
|
||||
const request = dependencies.fetch ?? fetch;
|
||||
const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000);
|
||||
const repositoryParts = config.targetRepository.split('/');
|
||||
|
||||
if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) {
|
||||
throw new Error('TARGET_REPOSITORY must use the owner/repository format.');
|
||||
}
|
||||
|
||||
const [, repository] = repositoryParts;
|
||||
const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds);
|
||||
const jwt = signJwt(signingInput, config, execute);
|
||||
|
||||
const response = await request(
|
||||
`https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repositories: [repository],
|
||||
permissions: {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
members: 'read',
|
||||
pull_requests: 'write',
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (typeof result.token !== 'string' || result.token.length === 0) {
|
||||
throw new Error('GitHub returned an empty installation token.');
|
||||
}
|
||||
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function readConfig(environment) {
|
||||
const config = {
|
||||
azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID,
|
||||
keyVaultName: environment.KEY_VAULT_NAME,
|
||||
keyName: environment.KEY_NAME,
|
||||
githubAppClientId: environment.GITHUB_APP_CLIENT_ID,
|
||||
githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID,
|
||||
targetRepository: environment.TARGET_REPOSITORY,
|
||||
};
|
||||
|
||||
if (Object.values(config).some((value) => !value)) {
|
||||
throw new Error('Required GitHub App authentication configuration is missing.');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const token = await createInstallationToken(readConfig(process.env));
|
||||
process.stdout.write(token);
|
||||
} catch {
|
||||
console.error('GitHub App token generation failed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
base64ToBase64Url,
|
||||
createInstallationToken,
|
||||
createJwtSigningInput,
|
||||
readConfig,
|
||||
signJwt,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
@@ -32,13 +32,7 @@ runs:
|
||||
if grep -q "name = \"$pkg\"" "$f"; then
|
||||
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
||||
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
||||
if awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/{found=1} END{exit !found}' python/pyproject.toml; then
|
||||
if ! awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/ && index($0, "\"'"$pkg_dir"'\"")' python/pyproject.toml | grep -q .; then
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/,/^\[/ { /^exclude = \[/ s|\]|, "'"$pkg_dir"'"]| }' python/pyproject.toml
|
||||
fi
|
||||
else
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
fi
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
||||
fi
|
||||
done
|
||||
@@ -46,4 +40,4 @@ runs:
|
||||
- name: Install the project
|
||||
shell: bash
|
||||
run: |
|
||||
cd python && uv sync --all-packages --all-extras --all-groups --prerelease=if-necessary-or-explicit
|
||||
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Save Sample Playbooks
|
||||
description: >
|
||||
Save the cached sample-validation playbooks. Split out from
|
||||
sample-validation-setup (which only restores) so the save runs even when the
|
||||
validation step fails. Combining restore+save via actions/cache would skip the
|
||||
save on a failing job (post-if: success()), so freshly authored playbooks for
|
||||
samples that failed validation would never persist. Invoke this with
|
||||
'if: not-cancelled' after the validation step in each job.
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Save sample playbooks cache
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
# Must match the restore path/key in sample-validation-setup/action.yml and the
|
||||
# sample_validation --playbooks-dir default (samples/sample_validation/playbooks).
|
||||
path: python/samples/sample_validation/playbooks/
|
||||
key: sample-playbooks-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -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@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -36,31 +36,15 @@ runs:
|
||||
shell: bash
|
||||
run: copilot --version && copilot -p "What can you do in one sentence?"
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
os: ${{ inputs.os }}
|
||||
|
||||
- name: Restore sample playbooks
|
||||
# Restore-only. The matching save is a separate step in each job that runs with
|
||||
# `if: ${{ !cancelled() }}` (see .github/actions/sample-validation-save-playbooks).
|
||||
# A combined actions/cache would skip its post-job save on a failing job
|
||||
# (post-if: success()), so playbooks authored for samples that failed validation
|
||||
# would never persist. Keyed per job so each validate-* job keeps its own playbooks.
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
# Must match the sample_validation --playbooks-dir default, which resolves to
|
||||
# samples/sample_validation/playbooks (see python/scripts/sample_validation/__main__.py).
|
||||
# If a job overrides --playbooks-dir, update this path to match.
|
||||
path: python/samples/sample_validation/playbooks/
|
||||
key: sample-playbooks-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
sample-playbooks-${{ github.job }}-
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
@@ -24,14 +24,12 @@ updates:
|
||||
- ".NET"
|
||||
- "dependencies"
|
||||
|
||||
# Maintain dependencies for python.
|
||||
# TODO: Remove these Python Dependabot entries after we have confidence in the
|
||||
# Python dependency-maintenance workflow.
|
||||
# Maintain dependencies for python
|
||||
- package-ecosystem: "pip"
|
||||
directory: "python/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "thursday"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "python"
|
||||
- "dependencies"
|
||||
@@ -39,24 +37,16 @@ updates:
|
||||
directory: "python/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "thursday"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "python"
|
||||
- "dependencies"
|
||||
|
||||
# Maintain dependencies for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Cover both the standard workflow location and our composite actions.
|
||||
# With `directory: "/"` Dependabot only scans `.github/workflows/*.{yml,yaml}`
|
||||
# plus a root-level `action.yml/action.yaml`. It does NOT recurse into
|
||||
# `.github/actions/*/action.yml`, so the glob below is required to keep the
|
||||
# composite actions in `.github/actions/<name>/` up to date as well.
|
||||
# Ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#directories-or-directory--
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/*"
|
||||
# Workflow files stored in the
|
||||
# default location of `.github/workflows`
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "sunday"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
applyTo: "dotnet/src/Microsoft.Agents.AI.DurableTask/**,dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**"
|
||||
---
|
||||
|
||||
# Durable Task area code instructions
|
||||
|
||||
The following guidelines apply to pull requests that modify files under
|
||||
`dotnet/src/Microsoft.Agents.AI.DurableTask/**` or
|
||||
`dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**`:
|
||||
|
||||
## CHANGELOG.md
|
||||
|
||||
- Each pull request that modifies code should add just one bulleted entry to the `CHANGELOG.md` file containing a change title (usually the PR title) and a link to the PR itself.
|
||||
- New PRs should be added to the top of the `CHANGELOG.md` file under a "## [Unreleased]" heading.
|
||||
- If the PR is the first since the last release, the existing "## [Unreleased]" heading should be replaced with a "## v[X.Y.Z]" heading and the PRs since the last release should be added to the new "## [Unreleased]" heading.
|
||||
- The style of new `CHANGELOG.md` entries should match the style of the other entries in the file.
|
||||
- If the PR introduces a breaking change, the changelog entry should be prefixed with "[BREAKING]".
|
||||
@@ -1,43 +1,23 @@
|
||||
### Motivation & Context
|
||||
### Motivation and Context
|
||||
|
||||
<!-- Thank you for your contribution to the Agent Framework repo!
|
||||
Please help reviewers and future users, providing the following information:
|
||||
1. Why is this change required?
|
||||
2. What problem does it solve?
|
||||
3. What scenario does it contribute to?
|
||||
4. If it fixes an open issue, please link to the issue below.
|
||||
4. If it fixes an open issue, please link to the issue here.
|
||||
-->
|
||||
|
||||
### Description & Review Guide
|
||||
### Description
|
||||
|
||||
<!-- Describe your changes, the overall approach, the underlying design.
|
||||
Highlight what you want the reviewers to focus on.
|
||||
These notes will help understanding how your code works. Thanks! -->
|
||||
|
||||
- **What are the major changes?**
|
||||
- **What is the impact of these changes?**
|
||||
- **What do you want reviewers to focus on?**
|
||||
<!-- NOTE FOR AUTOMATED/AI REVIEWERS: the "What do you want reviewers to focus on?"
|
||||
item above is intended for human reviewers only. Automated/AI reviewers should
|
||||
ignore it and review the entire change rather than narrowing scope to it. -->
|
||||
|
||||
|
||||
### Related Issue
|
||||
|
||||
<!-- Which issue does this PR fix? Link it using a GitHub closing keyword so it is
|
||||
closed automatically when this PR is merged, e.g. "Fixes #123" or "Closes #123".
|
||||
PRs that are not linked to an issue may be closed, no matter how valid the change is.
|
||||
Also check whether an open PR already exists for this issue; if so,
|
||||
explain how this PR is different. -->
|
||||
|
||||
Fixes #
|
||||
|
||||
### Contribution Checklist
|
||||
|
||||
<!-- Before submitting this PR, please make sure: -->
|
||||
|
||||
- [ ] The code builds clean without any errors or warnings
|
||||
- [ ] All unit tests pass, and I have added new tests where possible
|
||||
- [ ] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
|
||||
- [ ] This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
|
||||
- [x] **This is not a breaking change.** If it _is_ a breaking change, add the `breaking change` label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.
|
||||
- [ ] All unit tests pass, and I have added new tests where possible
|
||||
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
|
||||
@@ -1,40 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue or pull request author and check their team membership.
|
||||
* Resolve the issue author and check their team membership.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.github - Octokit REST client from actions/github-script
|
||||
* @param {object} opts.context - GitHub Actions context
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue or pull request number to resolve author for
|
||||
* @param {string} [opts.username] - Explicit user to check instead of the issue or pull request author
|
||||
* @param {string|number} opts.issueNumber - Issue number to resolve author for
|
||||
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
|
||||
*/
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber, username = '' }) {
|
||||
let author = username.trim() || (
|
||||
context.payload.issue?.user?.login ??
|
||||
context.payload.pull_request?.user?.login
|
||||
);
|
||||
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
|
||||
let author = context.payload.issue?.user?.login;
|
||||
if (!author) {
|
||||
const number = Number(issueNumber);
|
||||
if (context.payload.pull_request) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: number,
|
||||
});
|
||||
author = pr.user?.login;
|
||||
} else {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: number,
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(issueNumber),
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
function getPullRequest(context) {
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest?.number || !pullRequest.user?.login) {
|
||||
throw new Error('This script must be run from a pull_request_target event.');
|
||||
}
|
||||
|
||||
return {
|
||||
author: pullRequest.user.login,
|
||||
authorType: pullRequest.user.type,
|
||||
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
|
||||
number: pullRequest.number,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureLabel({ github, owner, repo, labelName }) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: labelName,
|
||||
color: 'd93f0b',
|
||||
description: 'Community author has exceeded the open pull request limit.',
|
||||
});
|
||||
} catch (createError) {
|
||||
if (createError.status !== 422) {
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasLabel(labels, labelName) {
|
||||
if (!labelName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
|
||||
}
|
||||
|
||||
function isDependabotAuthor({ author, authorType }) {
|
||||
return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]';
|
||||
}
|
||||
|
||||
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
|
||||
return [
|
||||
`Thank you for your contribution, @${author}.`,
|
||||
'',
|
||||
`To keep the review queue manageable, we currently limit community contributors to ${maxOpenPrs} `
|
||||
+ `open pull requests at a time. This PR would put you at ${openPrCount} open pull requests, `
|
||||
+ 'so we are closing it automatically.',
|
||||
'',
|
||||
'Please focus on getting your existing PRs reviewed, merged, or closed before opening another one. '
|
||||
+ `If a maintainer asked you to open this PR, they can apply the \`${exemptLabelName}\` label and reopen it.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }) {
|
||||
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const authorOpenPullRequestNumbers = openPullRequests
|
||||
.filter((pullRequest) => pullRequest.user?.login === author)
|
||||
.map((pullRequest) => pullRequest.number);
|
||||
const currentPrIsOpen = authorOpenPullRequestNumbers.includes(pullRequestNumber);
|
||||
const existingOpenPrCount = currentPrIsOpen
|
||||
? authorOpenPullRequestNumbers.length - 1
|
||||
: authorOpenPullRequestNumbers.length;
|
||||
|
||||
return existingOpenPrCount + 1;
|
||||
}
|
||||
|
||||
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const { author, authorType, labels, number } = getPullRequest(context);
|
||||
|
||||
if (isDependabotAuthor({ author, authorType })) {
|
||||
core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`);
|
||||
return {
|
||||
author,
|
||||
closed: false,
|
||||
dependabotExempt: true,
|
||||
openPrCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLabel(labels, exemptLabelName)) {
|
||||
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
|
||||
return {
|
||||
author,
|
||||
closed: false,
|
||||
exempt: true,
|
||||
openPrCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
const openPrCount = await getOpenPrCount({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
author,
|
||||
pullRequestNumber: number,
|
||||
});
|
||||
|
||||
if (openPrCount <= maxOpenPrs) {
|
||||
core.info(
|
||||
`${author} has ${openPrCount} open pull request(s), which is within the limit of ${maxOpenPrs}.`,
|
||||
);
|
||||
return {
|
||||
author,
|
||||
closed: false,
|
||||
openPrCount,
|
||||
};
|
||||
}
|
||||
|
||||
await ensureLabel({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
labelName,
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
labels: [labelName],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
body: buildLimitMessage({
|
||||
author,
|
||||
exemptLabelName,
|
||||
maxOpenPrs,
|
||||
openPrCount,
|
||||
}),
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(
|
||||
`${author} has ${openPrCount} open pull request(s), which exceeds the limit of ${maxOpenPrs}. `
|
||||
+ `Closed PR #${number}.`,
|
||||
);
|
||||
|
||||
return {
|
||||
author,
|
||||
closed: true,
|
||||
openPrCount,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildLimitMessage,
|
||||
enforcePrLimit,
|
||||
getOpenPrCount,
|
||||
};
|
||||
@@ -1,212 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Enforce Python package coverage according to package lifecycle."""
|
||||
|
||||
# ruff:file-ignore[print]
|
||||
# ruff:file-ignore[implicit-namespace-package]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import]
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
DEVELOPMENT_STATUS_PREFIX = "Development Status :: "
|
||||
ENFORCED_DEVELOPMENT_STATUS = 4
|
||||
EXEMPT_PACKAGES = {"devui", "lab"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackagePolicy:
|
||||
"""Coverage policy derived from a package's project metadata."""
|
||||
|
||||
directory: str
|
||||
distribution_name: str
|
||||
development_status: int
|
||||
development_status_label: str
|
||||
enforced: bool
|
||||
exempt: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoverageStats:
|
||||
"""Line and branch coverage counters."""
|
||||
|
||||
lines_valid: int = 0
|
||||
lines_covered: int = 0
|
||||
branches_valid: int = 0
|
||||
branches_covered: int = 0
|
||||
|
||||
@property
|
||||
def line_coverage_percent(self) -> float:
|
||||
"""Return line coverage as a percentage."""
|
||||
if not self.lines_valid:
|
||||
return 0
|
||||
return self.lines_covered / self.lines_valid * 100
|
||||
|
||||
|
||||
def normalize_coverage_path(path: str) -> str:
|
||||
"""Normalize a coverage path for matching."""
|
||||
return path.replace("\\", "/").lstrip("./")
|
||||
|
||||
|
||||
def load_package_policies(packages_dir: Path) -> list[PackagePolicy]:
|
||||
"""Load lifecycle-based coverage policies from package pyproject files."""
|
||||
policies: list[PackagePolicy] = []
|
||||
for pyproject_path in sorted(packages_dir.glob("*/pyproject.toml")):
|
||||
with pyproject_path.open("rb") as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
|
||||
project = pyproject.get("project", {})
|
||||
distribution_name = str(project.get("name", "")).strip()
|
||||
if not distribution_name:
|
||||
raise ValueError(f"{pyproject_path}: project.name is required")
|
||||
|
||||
status_classifiers = [
|
||||
classifier
|
||||
for classifier in project.get("classifiers", [])
|
||||
if classifier.startswith(DEVELOPMENT_STATUS_PREFIX)
|
||||
]
|
||||
if len(status_classifiers) != 1:
|
||||
raise ValueError(
|
||||
f"{pyproject_path}: expected exactly one Development Status classifier, found {len(status_classifiers)}"
|
||||
)
|
||||
|
||||
match = re.fullmatch(r"Development Status :: (\d+) - (.+)", status_classifiers[0])
|
||||
if match is None:
|
||||
raise ValueError(f"{pyproject_path}: malformed Development Status classifier")
|
||||
|
||||
directory = pyproject_path.parent.name
|
||||
development_status = int(match.group(1))
|
||||
exempt = directory in EXEMPT_PACKAGES
|
||||
policies.append(
|
||||
PackagePolicy(
|
||||
directory=directory,
|
||||
distribution_name=distribution_name,
|
||||
development_status=development_status,
|
||||
development_status_label=match.group(2),
|
||||
enforced=development_status >= ENFORCED_DEVELOPMENT_STATUS and not exempt,
|
||||
exempt=exempt,
|
||||
)
|
||||
)
|
||||
|
||||
if not policies:
|
||||
raise ValueError(f"No package pyproject.toml files found below {packages_dir}")
|
||||
return policies
|
||||
|
||||
|
||||
def parse_coverage_xml(xml_path: Path) -> tuple[dict[str, CoverageStats], float, float]:
|
||||
"""Parse Cobertura XML and aggregate coverage by package directory."""
|
||||
root = ET.parse(xml_path).getroot() # ruff:ignore[suspicious-xml-element-tree-usage] # Trusted CI-generated coverage report.
|
||||
package_stats: dict[str, CoverageStats] = {}
|
||||
|
||||
for class_elem in root.findall(".//class"):
|
||||
file_path = normalize_coverage_path(class_elem.get("filename", ""))
|
||||
path_parts = file_path.split("/")
|
||||
try:
|
||||
packages_index = path_parts.index("packages")
|
||||
package_directory = path_parts[packages_index + 1]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
stats = package_stats.setdefault(package_directory, CoverageStats())
|
||||
for line in class_elem.findall(".//line"):
|
||||
stats.lines_valid += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
stats.lines_covered += 1
|
||||
|
||||
if line.get("branch") != "true":
|
||||
continue
|
||||
condition_coverage = line.get("condition-coverage", "")
|
||||
match = re.search(r"\((\d+)/(\d+)\)", condition_coverage)
|
||||
if match is not None:
|
||||
stats.branches_covered += int(match.group(1))
|
||||
stats.branches_valid += int(match.group(2))
|
||||
|
||||
return (
|
||||
package_stats,
|
||||
float(root.get("line-rate", 0)) * 100,
|
||||
float(root.get("branch-rate", 0)) * 100,
|
||||
)
|
||||
|
||||
|
||||
def check_coverage(xml_path: Path, threshold: float, packages_dir: Path) -> bool:
|
||||
"""Check all lifecycle-enforced packages against the coverage threshold."""
|
||||
policies = load_package_policies(packages_dir)
|
||||
package_stats, overall_line_coverage, overall_branch_coverage = parse_coverage_xml(xml_path)
|
||||
|
||||
print("\n" + "=" * 110)
|
||||
print("PYTHON PACKAGE TEST COVERAGE")
|
||||
print("=" * 110)
|
||||
print(f"Overall Line Coverage: {overall_line_coverage:.1f}%")
|
||||
print(f"Overall Branch Coverage: {overall_branch_coverage:.1f}%")
|
||||
print(f"Enforced Threshold: {threshold:.1f}%")
|
||||
print("-" * 110)
|
||||
print(f"{'Package':<48} {'Stage':<20} {'Policy':<14} {'Lines':<12} {'Line Cov':<10}")
|
||||
print("-" * 110)
|
||||
|
||||
failed_packages: list[str] = []
|
||||
for policy in sorted(policies, key=lambda item: (not item.enforced, item.distribution_name)):
|
||||
stats = package_stats.get(policy.directory)
|
||||
if policy.exempt:
|
||||
policy_label = "EXEMPT"
|
||||
elif policy.enforced:
|
||||
policy_label = "ENFORCED"
|
||||
else:
|
||||
policy_label = "REPORT ONLY"
|
||||
|
||||
if stats is None:
|
||||
lines = "-"
|
||||
coverage = "missing"
|
||||
if policy.enforced:
|
||||
failed_packages.append(f"{policy.distribution_name} (missing from coverage report)")
|
||||
else:
|
||||
lines = f"{stats.lines_covered}/{stats.lines_valid}"
|
||||
coverage = f"{stats.line_coverage_percent:.1f}%"
|
||||
if policy.enforced and stats.line_coverage_percent < threshold:
|
||||
failed_packages.append(f"{policy.distribution_name} ({coverage})")
|
||||
|
||||
stage = f"{policy.development_status} - {policy.development_status_label}"
|
||||
print(f"{policy.distribution_name:<48} {stage:<20} {policy_label:<14} {lines:<12} {coverage:<10}")
|
||||
|
||||
print("-" * 110)
|
||||
if failed_packages:
|
||||
print(f"\nFAILED: Enforced packages below {threshold:.1f}% or missing:")
|
||||
for package in failed_packages:
|
||||
print(f" - {package}")
|
||||
return False
|
||||
|
||||
print(f"\nPASSED: All non-exempt Beta-or-higher packages meet {threshold:.1f}% line coverage.")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the coverage policy check."""
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
|
||||
return 1
|
||||
|
||||
try:
|
||||
threshold = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Error: Invalid threshold value: {sys.argv[2]}")
|
||||
return 1
|
||||
|
||||
repository_root = Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
passed = check_coverage(
|
||||
Path(sys.argv[1]),
|
||||
threshold,
|
||||
repository_root / "python" / "packages",
|
||||
)
|
||||
except (FileNotFoundError, ET.ParseError, ValueError) as error:
|
||||
print(f"Error: {error}")
|
||||
return 1
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,170 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
||||
const BRANCH_PATTERN = /^[a-zA-Z0-9_./-]+$/;
|
||||
|
||||
function assertValidSha(sha, description) {
|
||||
if (!SHA_PATTERN.test(sha)) {
|
||||
throw new Error(`GitHub returned an invalid ${description} SHA.`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasWritePermission(permissionData) {
|
||||
return permissionData.user?.permissions?.push === true
|
||||
|| ['admin', 'maintain', 'write'].includes(permissionData.permission);
|
||||
}
|
||||
|
||||
function latestDecisiveReviews(reviews) {
|
||||
const latestByReviewer = new Map();
|
||||
const sortedReviews = [...reviews].sort((left, right) => {
|
||||
const submittedComparison = (left.submitted_at || '').localeCompare(right.submitted_at || '');
|
||||
return submittedComparison || Number(left.id) - Number(right.id);
|
||||
});
|
||||
|
||||
for (const review of sortedReviews) {
|
||||
const state = review.state?.toUpperCase();
|
||||
const reviewer = review.user?.login?.toLowerCase();
|
||||
if (reviewer && DECISIVE_REVIEW_STATES.has(state)) {
|
||||
latestByReviewer.set(reviewer, review);
|
||||
}
|
||||
}
|
||||
|
||||
return latestByReviewer;
|
||||
}
|
||||
|
||||
async function resolvePullRequest({ github, context, core, prNumber, requiredApprovals }) {
|
||||
if (!/^[0-9]+$/.test(prNumber)) {
|
||||
throw new Error('Invalid PR number. Only numeric values are allowed.');
|
||||
}
|
||||
|
||||
const pullNumber = Number(prNumber);
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
if (pullRequest.state !== 'open') {
|
||||
throw new Error(`PR #${pullNumber} is not open (state: ${pullRequest.state}).`);
|
||||
}
|
||||
|
||||
const headSha = pullRequest.head.sha;
|
||||
const baseSha = pullRequest.base.sha;
|
||||
assertValidSha(headSha, 'PR head');
|
||||
assertValidSha(baseSha, 'PR base');
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
...context.repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const latestReviews = latestDecisiveReviews(reviews);
|
||||
const author = pullRequest.user?.login?.toLowerCase();
|
||||
const approvalCandidates = [...latestReviews.entries()]
|
||||
.filter(([, review]) => review.state.toUpperCase() === 'APPROVED')
|
||||
.filter(([, review]) => review.commit_id === headSha)
|
||||
.filter(([reviewer]) => reviewer !== author);
|
||||
|
||||
const approvedMaintainers = [];
|
||||
for (const [reviewer] of approvalCandidates) {
|
||||
const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
...context.repo,
|
||||
username: reviewer,
|
||||
});
|
||||
if (hasWritePermission(permissionData)) {
|
||||
approvedMaintainers.push(reviewer);
|
||||
} else {
|
||||
core.info(`Ignoring approval from ${reviewer}: reviewer does not have write permission.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (approvedMaintainers.length < requiredApprovals) {
|
||||
throw new Error(
|
||||
`PR #${pullNumber} head ${headSha} requires ${requiredApprovals} approvals from unique `
|
||||
+ `write-capable maintainers; found ${approvedMaintainers.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
core.info(
|
||||
`PR #${pullNumber} head ${headSha} approved by: ${approvedMaintainers.join(', ')}.`,
|
||||
);
|
||||
return {
|
||||
baseRef: baseSha,
|
||||
checkoutRef: headSha,
|
||||
description: `PR #${pullNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBranch({ github, context, core, branch }) {
|
||||
if (!BRANCH_PATTERN.test(branch)) {
|
||||
throw new Error(
|
||||
'Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes '
|
||||
+ 'are allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const [{ data: repository }, { data: targetBranch }] = await Promise.all([
|
||||
github.rest.repos.get(context.repo),
|
||||
github.rest.repos.getBranch({ ...context.repo, branch }),
|
||||
]);
|
||||
const { data: baseBranch } = await github.rest.repos.getBranch({
|
||||
...context.repo,
|
||||
branch: repository.default_branch,
|
||||
});
|
||||
|
||||
const checkoutRef = targetBranch.commit.sha;
|
||||
const baseRef = baseBranch.commit.sha;
|
||||
assertValidSha(checkoutRef, 'branch head');
|
||||
assertValidSha(baseRef, 'default branch');
|
||||
core.info(`Branch ${branch} resolved to immutable commit ${checkoutRef}.`);
|
||||
|
||||
return {
|
||||
baseRef,
|
||||
checkoutRef,
|
||||
description: `branch ${branch}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manually requested integration-test target to an immutable commit.
|
||||
*
|
||||
* Pull requests must have fresh approvals from two unique write-capable
|
||||
* maintainers for the exact head commit. Branches are limited to branches in
|
||||
* the base repository and are pinned to their current commit.
|
||||
*/
|
||||
async function resolveIntegrationTestTarget({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber = '',
|
||||
branch = '',
|
||||
requiredApprovals = 2,
|
||||
}) {
|
||||
const normalizedPrNumber = prNumber.trim();
|
||||
const normalizedBranch = branch.trim();
|
||||
|
||||
if (normalizedPrNumber && normalizedBranch) {
|
||||
throw new Error('Please provide either a PR number or a branch name, not both.');
|
||||
}
|
||||
if (!normalizedPrNumber && !normalizedBranch) {
|
||||
throw new Error('Please provide either a PR number or a branch name.');
|
||||
}
|
||||
|
||||
if (normalizedPrNumber) {
|
||||
return resolvePullRequest({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber: normalizedPrNumber,
|
||||
requiredApprovals,
|
||||
});
|
||||
}
|
||||
return resolveBranch({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
branch: normalizedBranch,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = resolveIntegrationTestTarget;
|
||||
@@ -1,13 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Check whether a comment contains only the DevFlow review command.
|
||||
*
|
||||
* @param {unknown} body - Issue comment body from the GitHub event payload.
|
||||
* @returns {boolean} Whether the normalized comment is exactly `/review`.
|
||||
*/
|
||||
function isReviewCommand(body) {
|
||||
return typeof body === 'string' && body.trim() === '/review';
|
||||
}
|
||||
|
||||
module.exports = isReviewCommand;
|
||||
@@ -1,253 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const BREAKING_CHANGE_LABEL = 'breaking change';
|
||||
const BREAKING_PREFIX = '[BREAKING]';
|
||||
|
||||
const DEFAULT_PREFIX_LABELS = Object.freeze({
|
||||
python: 'Python',
|
||||
'.NET': '.NET',
|
||||
});
|
||||
|
||||
const DEFAULT_BRACKET_PREFIX_LABELS = Object.freeze({
|
||||
[BREAKING_CHANGE_LABEL]: BREAKING_PREFIX,
|
||||
});
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getMatchingValueByKey(valuesByKey, keyToFind) {
|
||||
const matchingKey = Object.keys(valuesByKey).find((key) => key.toLowerCase() === keyToFind.toLowerCase());
|
||||
return matchingKey === undefined ? null : valuesByKey[matchingKey];
|
||||
}
|
||||
|
||||
function getPrefixPattern(prefixes) {
|
||||
return prefixes.map(escapeRegExp).join('|');
|
||||
}
|
||||
|
||||
function canonicalizePrefix(prefix, prefixes) {
|
||||
return prefixes.find((knownPrefix) => knownPrefix.toLowerCase() === prefix.toLowerCase()) ?? prefix;
|
||||
}
|
||||
|
||||
function normalizeLeadingBracketPrefix(title, bracketPrefixes) {
|
||||
const bracketPattern = getPrefixPattern(bracketPrefixes);
|
||||
if (!bracketPattern) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const leadingBracketPrefix = new RegExp(`^(${bracketPattern})(?=\\s|$)`, 'i');
|
||||
return title.replace(
|
||||
leadingBracketPrefix,
|
||||
(bracketPrefix) => canonicalizePrefix(bracketPrefix, bracketPrefixes),
|
||||
);
|
||||
}
|
||||
|
||||
function parseLeadingTitlePrefix(title, titlePrefixes) {
|
||||
const titlePrefixPattern = getPrefixPattern(titlePrefixes);
|
||||
if (!titlePrefixPattern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = title.match(new RegExp(`^(${titlePrefixPattern}):\\s*`, 'i'));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
prefix: canonicalizePrefix(match[1], titlePrefixes),
|
||||
rest: title.slice(match[0].length).trimStart(),
|
||||
};
|
||||
}
|
||||
|
||||
function removeBracketPrefixToken(title, bracketPrefix) {
|
||||
const bracketPrefixPattern = escapeRegExp(bracketPrefix);
|
||||
return title
|
||||
.replace(new RegExp(`(^|\\s+)${bracketPrefixPattern}(?=\\s|$)`, 'ig'), '$1')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function addTitlePrefix(title, prefix, bracketPrefixes = Object.values(DEFAULT_BRACKET_PREFIX_LABELS)) {
|
||||
const bracketPattern = getPrefixPattern(bracketPrefixes);
|
||||
const prefixPattern = escapeRegExp(prefix);
|
||||
|
||||
if (bracketPattern) {
|
||||
const bracketThenTitlePrefix = new RegExp(`^(${bracketPattern})(\\s+)(${prefixPattern})(?=:)`, 'i');
|
||||
if (bracketThenTitlePrefix.test(title)) {
|
||||
return title.replace(
|
||||
bracketThenTitlePrefix,
|
||||
(match, bracketPrefix, spacing) => `${canonicalizePrefix(bracketPrefix, bracketPrefixes)}${spacing}${prefix}`,
|
||||
);
|
||||
}
|
||||
|
||||
title = normalizeLeadingBracketPrefix(title, bracketPrefixes);
|
||||
}
|
||||
|
||||
if (!title.startsWith(`${prefix}: `)) {
|
||||
const existingTitlePrefix = new RegExp(`^${prefixPattern}:\\s*`, 'i');
|
||||
if (existingTitlePrefix.test(title)) {
|
||||
return title.replace(existingTitlePrefix, `${prefix}: `);
|
||||
}
|
||||
|
||||
return `${prefix}: ${title}`;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
function hasBracketPrefix(title, bracketPrefix, titlePrefixes = Object.values(DEFAULT_PREFIX_LABELS)) {
|
||||
const bracketPrefixPattern = escapeRegExp(bracketPrefix);
|
||||
const leadingBracketPrefix = new RegExp(`^${bracketPrefixPattern}(?=\\s|$)`, 'i');
|
||||
if (leadingBracketPrefix.test(title)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const leadingTitlePrefix = parseLeadingTitlePrefix(title, titlePrefixes);
|
||||
if (!leadingTitlePrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return leadingBracketPrefix.test(leadingTitlePrefix.rest);
|
||||
}
|
||||
|
||||
function addBracketPrefix(title, bracketPrefix, titlePrefixes = Object.values(DEFAULT_PREFIX_LABELS)) {
|
||||
const bracketPrefixPattern = escapeRegExp(bracketPrefix);
|
||||
const leadingBracketPrefix = new RegExp(`^${bracketPrefixPattern}(?=\\s|$)`, 'i');
|
||||
if (leadingBracketPrefix.test(title)) {
|
||||
return title.replace(leadingBracketPrefix, bracketPrefix);
|
||||
}
|
||||
|
||||
const leadingTitlePrefix = parseLeadingTitlePrefix(title, titlePrefixes);
|
||||
if (leadingTitlePrefix) {
|
||||
if (leadingBracketPrefix.test(leadingTitlePrefix.rest)) {
|
||||
const normalizedRest = leadingTitlePrefix.rest.replace(leadingBracketPrefix, bracketPrefix);
|
||||
return `${leadingTitlePrefix.prefix}: ${normalizedRest}`;
|
||||
}
|
||||
|
||||
const titleWithoutBracketPrefix = removeBracketPrefixToken(leadingTitlePrefix.rest, bracketPrefix);
|
||||
return `${leadingTitlePrefix.prefix}: ${bracketPrefix}`
|
||||
+ (titleWithoutBracketPrefix ? ` ${titleWithoutBracketPrefix}` : '');
|
||||
}
|
||||
|
||||
const titleWithoutBracketPrefix = removeBracketPrefixToken(title, bracketPrefix);
|
||||
return `${bracketPrefix}${titleWithoutBracketPrefix ? ` ${titleWithoutBracketPrefix}` : ''}`;
|
||||
}
|
||||
|
||||
function hasLabel(labels, labelName) {
|
||||
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
|
||||
}
|
||||
|
||||
function getCurrentTitle(context) {
|
||||
switch (context.eventName) {
|
||||
case 'issues':
|
||||
return context.payload.issue.title;
|
||||
case 'pull_request_target':
|
||||
return context.payload.pull_request.title;
|
||||
default:
|
||||
throw new Error(`Unrecognized eventName: ${context.eventName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTitleForAddedLabel({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prefixLabels = DEFAULT_PREFIX_LABELS,
|
||||
bracketPrefixLabels = DEFAULT_BRACKET_PREFIX_LABELS,
|
||||
}) {
|
||||
const labelAdded = context.payload.label?.name;
|
||||
if (!labelAdded) {
|
||||
throw new Error('This script must be run from a labeled event.');
|
||||
}
|
||||
|
||||
const currentTitle = getCurrentTitle(context);
|
||||
let newTitle = null;
|
||||
|
||||
const titlePrefix = getMatchingValueByKey(prefixLabels, labelAdded);
|
||||
if (titlePrefix !== null) {
|
||||
newTitle = addTitlePrefix(currentTitle, titlePrefix, Object.values(bracketPrefixLabels));
|
||||
}
|
||||
|
||||
const bracketPrefix = getMatchingValueByKey(bracketPrefixLabels, labelAdded);
|
||||
if (bracketPrefix !== null) {
|
||||
newTitle = addBracketPrefix(currentTitle, bracketPrefix, Object.values(prefixLabels));
|
||||
}
|
||||
|
||||
if (newTitle === null) {
|
||||
core.info(`No title prefix configured for label "${labelAdded}".`);
|
||||
return { updated: false, newTitle: currentTitle };
|
||||
}
|
||||
|
||||
if (newTitle === currentTitle) {
|
||||
core.info(`Title already includes the prefix for label "${labelAdded}".`);
|
||||
return { updated: false, newTitle };
|
||||
}
|
||||
|
||||
switch (context.eventName) {
|
||||
case 'issues':
|
||||
await github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: newTitle,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'pull_request_target':
|
||||
await github.rest.pulls.update({
|
||||
pull_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: newTitle,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unrecognized eventName: ${context.eventName}`);
|
||||
}
|
||||
|
||||
return { updated: true, newTitle };
|
||||
}
|
||||
|
||||
async function syncBreakingChangeLabelFromTitle({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
labelName = BREAKING_CHANGE_LABEL,
|
||||
bracketPrefix = BREAKING_PREFIX,
|
||||
titlePrefixes = Object.values(DEFAULT_PREFIX_LABELS),
|
||||
}) {
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
throw new Error('This script must be run from a pull_request_target event.');
|
||||
}
|
||||
|
||||
const title = pullRequest.title || '';
|
||||
if (!hasBracketPrefix(title, bracketPrefix, titlePrefixes)) {
|
||||
core.info(`Title does not include ${bracketPrefix} in the title prefix.`);
|
||||
return { added: false };
|
||||
}
|
||||
|
||||
const labels = pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [];
|
||||
if (hasLabel(labels, labelName)) {
|
||||
core.info(`PR already has the "${labelName}" label.`);
|
||||
return { added: false };
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: [labelName],
|
||||
});
|
||||
|
||||
return { added: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addBracketPrefix,
|
||||
addTitlePrefix,
|
||||
hasBracketPrefix,
|
||||
syncBreakingChangeLabelFromTitle,
|
||||
updateTitleForAddedLabel,
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
name: pull-requests
|
||||
description: >
|
||||
Guidance for creating pull requests and handling PR review comments in the
|
||||
Agent Framework repository. Use this when writing a PR description (filling out
|
||||
the PR template) or when responding to and resolving review comments on an
|
||||
existing PR.
|
||||
---
|
||||
|
||||
# Pull Request Workflow
|
||||
|
||||
This skill covers two tasks: (1) writing a high-quality PR description, and
|
||||
(2) handling review comments on an existing PR.
|
||||
|
||||
## 1. Writing the PR description
|
||||
|
||||
Always follow the repository PR template at
|
||||
[`.github/pull_request_template.md`](../../pull_request_template.md). Keep its
|
||||
exact structure and headings. Fill every section:
|
||||
|
||||
### `### Motivation & Context`
|
||||
Explain *why* the change is needed: the problem it solves and the scenario it
|
||||
contributes to. Describe the net change relative to `main` — this is implied, so
|
||||
do **not** spell out "vs main" explicitly.
|
||||
|
||||
### `### Description & Review Guide`
|
||||
Describe the changes, the overall approach, and the design. Answer the three
|
||||
prompts:
|
||||
- **What are the major changes?**
|
||||
- **What is the impact of these changes?**
|
||||
- **What do you want reviewers to focus on?** — This item is for **human
|
||||
reviewers only**. Automated/AI reviewers must ignore it and review the entire
|
||||
change rather than narrowing scope to it.
|
||||
|
||||
### `### Related Issue`
|
||||
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
|
||||
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
|
||||
be closed regardless of how valid the change is. Before opening, confirm there is
|
||||
no other open PR for the same issue; if there is, explain how this PR differs.
|
||||
|
||||
### `### Contribution Checklist`
|
||||
Check every item that applies. For the breaking-change item:
|
||||
- Leave **"This is not a breaking change."** checked for the common case.
|
||||
- If the change **is** breaking, add the `breaking change` label **or** put
|
||||
`[BREAKING]` in the title prefix, before or after a language prefix such as
|
||||
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
|
||||
automatically (see `.github/workflows/label-title-prefix.yml` and
|
||||
`.github/workflows/label-pr.yml`).
|
||||
|
||||
### Do not
|
||||
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
|
||||
the checklist already cover validation status.
|
||||
- Do **not** remove or reorder the template's headings.
|
||||
|
||||
### Creating the PR
|
||||
Open new PRs as **drafts** until they are ready for review. Example:
|
||||
|
||||
```bash
|
||||
gh pr create --repo microsoft/agent-framework --base main \
|
||||
--head <your-fork-owner>:<branch> --draft \
|
||||
--title "<concise title>" --body "<body following the template>"
|
||||
```
|
||||
|
||||
## 2. Handling review comments
|
||||
|
||||
When a PR receives review comments, follow this sequence — **do not start editing
|
||||
code before the user has reviewed the plan**:
|
||||
|
||||
1. **Review the comments.** Read every review comment and thread on the PR,
|
||||
including inline code comments and general review summaries.
|
||||
2. **Make a plan.** Produce a concrete plan describing how each comment will be
|
||||
addressed (or why it should not be, with reasoning).
|
||||
3. **Let the user review the plan.** Present the plan and wait for the user's
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed, preferably citing the commit containing the change. If the
|
||||
feedback was not addressed, explain why. Leave no comment unanswered.
|
||||
6. **Resolve completed threads yourself.** After replying and completing any
|
||||
necessary discussion, resolve the review thread. Do not wait for the reviewer
|
||||
or a maintainer to resolve it. Leave a thread open only while it has an
|
||||
unanswered question or active discussion.
|
||||
|
||||
### Useful commands
|
||||
|
||||
List review comments and threads:
|
||||
|
||||
```bash
|
||||
# Inline review comments
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments
|
||||
|
||||
# Review threads with resolution state (GraphQL)
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100){
|
||||
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner={owner} -F repo={repo} -F pr={pr}
|
||||
```
|
||||
|
||||
Reply to an inline review comment:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
|
||||
-f body="Addressed in <commit>: <explanation>"
|
||||
```
|
||||
|
||||
Resolve a review thread (needs the thread node id from the GraphQL query above):
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
mutation($threadId:ID!){
|
||||
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
|
||||
}' -F threadId={thread_id}
|
||||
```
|
||||
@@ -16,12 +16,7 @@ const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({
|
||||
payloadIssue = undefined,
|
||||
payloadPullRequest = undefined,
|
||||
apiUser = 'api-user',
|
||||
teamState = 'active',
|
||||
} = {}) {
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
@@ -29,16 +24,8 @@ function createMocks({
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const payload = {};
|
||||
if (payloadIssue !== undefined) {
|
||||
payload.issue = payloadIssue;
|
||||
}
|
||||
if (payloadPullRequest !== undefined) {
|
||||
payload.pull_request = payloadPullRequest;
|
||||
}
|
||||
|
||||
const context = {
|
||||
payload,
|
||||
payload: { issue: payloadIssue },
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
@@ -49,11 +36,6 @@ function createMocks({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
@@ -74,28 +56,6 @@ const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('author resolution', () => {
|
||||
it('uses an explicit username instead of the issue author', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'issue-author' } },
|
||||
});
|
||||
let issuesGetCalled = false;
|
||||
github.rest.issues.get = async () => {
|
||||
issuesGetCalled = true;
|
||||
return { data: { user: { login: 'api-user' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
...BASE_OPTS,
|
||||
username: 'comment-author',
|
||||
});
|
||||
|
||||
assert.equal(result.author, 'comment-author');
|
||||
assert.equal(issuesGetCalled, false);
|
||||
});
|
||||
|
||||
it('resolves author from event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'payload-user' } },
|
||||
@@ -104,37 +64,6 @@ describe('author resolution', () => {
|
||||
assert.equal(result.author, 'payload-user');
|
||||
});
|
||||
|
||||
it('resolves author from pull_request event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadPullRequest: { user: { login: 'pr-author' } },
|
||||
});
|
||||
let issuesGetCalled = false;
|
||||
github.rest.issues.get = async () => {
|
||||
issuesGetCalled = true;
|
||||
return { data: { user: { login: 'api-user' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'pr-author');
|
||||
assert.equal(issuesGetCalled, false);
|
||||
});
|
||||
|
||||
it('resolves author via pulls API when pull_request payload user is null', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadPullRequest: { user: null },
|
||||
apiUser: 'fetched-pr-author',
|
||||
});
|
||||
let pullsGetCalled = false;
|
||||
github.rest.pulls.get = async () => {
|
||||
pullsGetCalled = true;
|
||||
return { data: { user: { login: 'fetched-pr-author' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'fetched-pr-author');
|
||||
assert.equal(pullsGetCalled, true);
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue is absent', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: 'api-user' });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
base64ToBase64Url,
|
||||
createInstallationToken,
|
||||
createJwtSigningInput,
|
||||
readConfig,
|
||||
} = require('../actions/github-app-token/create-token.js');
|
||||
|
||||
const CONFIG = {
|
||||
azureSubscriptionId: 'subscription-id',
|
||||
keyVaultName: 'vault-name',
|
||||
keyName: 'key-name',
|
||||
githubAppClientId: 'client-id',
|
||||
githubAppInstallationId: '12345',
|
||||
targetRepository: 'microsoft/agent-framework',
|
||||
};
|
||||
|
||||
describe('GitHub App token creation', () => {
|
||||
it('creates a short-lived GitHub App JWT', () => {
|
||||
const signingInput = createJwtSigningInput('client-id', 1_000);
|
||||
const [encodedHeader, encodedPayload] = signingInput.split('.');
|
||||
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString());
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString());
|
||||
|
||||
assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' });
|
||||
assert.deepEqual(payload, { iat: 940, exp: 1_540, iss: 'client-id' });
|
||||
});
|
||||
|
||||
it('converts Key Vault signatures to unpadded base64url', () => {
|
||||
assert.equal(base64ToBase64Url('+/8='), '-_8');
|
||||
});
|
||||
|
||||
it('requests a repository-scoped installation token', async () => {
|
||||
let request;
|
||||
const token = await createInstallationToken(CONFIG, {
|
||||
nowSeconds: 1_000,
|
||||
execute: (command, args) => {
|
||||
assert.equal(command, 'az');
|
||||
assert.ok(args.includes('RS256'));
|
||||
return '+/8=\n';
|
||||
},
|
||||
fetch: async (url, options) => {
|
||||
request = { url, options };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ token: 'installation-token' }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(token, 'installation-token');
|
||||
assert.equal(request.url, 'https://api.github.com/app/installations/12345/access_tokens');
|
||||
assert.match(request.options.headers.Authorization, /^Bearer [^.]+\.[^.]+\.-_8$/);
|
||||
assert.deepEqual(JSON.parse(request.options.body), {
|
||||
repositories: ['agent-framework'],
|
||||
permissions: {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
members: 'read',
|
||||
pull_requests: 'write',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete configuration', () => {
|
||||
assert.throws(
|
||||
() => readConfig({}),
|
||||
/Required GitHub App authentication configuration is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects repository values with extra path segments before signing', async () => {
|
||||
let signed = false;
|
||||
|
||||
await assert.rejects(
|
||||
createInstallationToken(
|
||||
{ ...CONFIG, targetRepository: 'microsoft/agent-framework/extra' },
|
||||
{
|
||||
execute: () => {
|
||||
signed = true;
|
||||
return '+/8=\n';
|
||||
},
|
||||
},
|
||||
),
|
||||
/TARGET_REPOSITORY must use the owner\/repository format/,
|
||||
);
|
||||
assert.equal(signed, false);
|
||||
});
|
||||
|
||||
it('rejects an empty Key Vault signature', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '\n',
|
||||
}),
|
||||
/Key Vault returned an empty signature/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a failed GitHub token request', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '+/8=\n',
|
||||
fetch: async () => ({ ok: false, status: 403 }),
|
||||
}),
|
||||
/GitHub installation token request failed with HTTP 403/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty GitHub installation token', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '+/8=\n',
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: '' }),
|
||||
}),
|
||||
}),
|
||||
/GitHub returned an empty installation token/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,341 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for pr_limit_moderation.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_pr_limit_moderation.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
|
||||
return {
|
||||
repo: {
|
||||
owner: 'microsoft',
|
||||
repo: 'agent-framework',
|
||||
},
|
||||
payload: {
|
||||
pull_request: {
|
||||
number,
|
||||
labels: labels.map((name) => ({ name })),
|
||||
user: {
|
||||
login: author,
|
||||
type: authorType,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCore() {
|
||||
const messages = [];
|
||||
return {
|
||||
messages,
|
||||
info(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGithub({
|
||||
itemNumbers,
|
||||
labelExists = true,
|
||||
pullRequests = createPullRequestPage({ numbers: itemNumbers }),
|
||||
}) {
|
||||
const calls = [];
|
||||
|
||||
return {
|
||||
calls,
|
||||
async paginate(method, params) {
|
||||
calls.push({ api: 'paginate', method, params });
|
||||
return pullRequests;
|
||||
},
|
||||
rest: {
|
||||
issues: {
|
||||
async getLabel(params) {
|
||||
calls.push({ api: 'issues.getLabel', params });
|
||||
if (!labelExists) {
|
||||
const error = new Error('Not Found');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return { data: { name: params.name } };
|
||||
},
|
||||
async createLabel(params) {
|
||||
calls.push({ api: 'issues.createLabel', params });
|
||||
return { data: { name: params.name } };
|
||||
},
|
||||
async addLabels(params) {
|
||||
calls.push({ api: 'issues.addLabels', params });
|
||||
return { data: [] };
|
||||
},
|
||||
async createComment(params) {
|
||||
calls.push({ api: 'issues.createComment', params });
|
||||
return { data: { id: 1 } };
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
async list(params) {
|
||||
calls.push({ api: 'pulls.list', params });
|
||||
return { data: pullRequests };
|
||||
},
|
||||
async update(params) {
|
||||
calls.push({ api: 'pulls.update', params });
|
||||
return { data: { state: params.state } };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPullRequestPage({ author = 'community-user', numbers }) {
|
||||
return numbers.map((number) => ({
|
||||
number,
|
||||
user: {
|
||||
login: author,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PR limit enforcement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PR limit enforcement', () => {
|
||||
it('does not close the PR when the author is at the open PR limit', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 123],
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext(),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, false);
|
||||
assert.equal(result.openPrCount, 10);
|
||||
assert.deepEqual(
|
||||
github.calls.map((call) => call.api),
|
||||
['paginate'],
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the new PR when the pull list includes it', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext(),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, true);
|
||||
assert.equal(result.openPrCount, 11);
|
||||
assert.deepEqual(
|
||||
github.calls.map((call) => call.api),
|
||||
[
|
||||
'paginate',
|
||||
'issues.getLabel',
|
||||
'issues.addLabels',
|
||||
'issues.createComment',
|
||||
'pulls.update',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the current PR on top of existing open PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 24 }, (_, index) => index + 1)],
|
||||
pullRequests: createPullRequestPage({
|
||||
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext(),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, true);
|
||||
assert.equal(result.openPrCount, 26);
|
||||
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
|
||||
assert.match(comment, /This PR would put you at 26 open pull requests/);
|
||||
});
|
||||
|
||||
it('creates the label when it does not already exist', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||
labelExists: false,
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext(),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, true);
|
||||
assert.deepEqual(
|
||||
github.calls.map((call) => call.api),
|
||||
[
|
||||
'paginate',
|
||||
'issues.getLabel',
|
||||
'issues.createLabel',
|
||||
'issues.addLabels',
|
||||
'issues.createComment',
|
||||
'pulls.update',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
github.calls.find((call) => call.api === 'issues.createLabel').params.name,
|
||||
'too-many-prs',
|
||||
);
|
||||
});
|
||||
|
||||
it('tolerates a 422 race when creating the label', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||
labelExists: false,
|
||||
});
|
||||
github.rest.issues.createLabel = async (params) => {
|
||||
github.calls.push({ api: 'issues.createLabel', params });
|
||||
const error = new Error('Validation Failed');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
};
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext(),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, true);
|
||||
assert.deepEqual(
|
||||
github.calls.map((call) => call.api),
|
||||
[
|
||||
'paginate',
|
||||
'issues.getLabel',
|
||||
'issues.createLabel',
|
||||
'issues.addLabels',
|
||||
'issues.createComment',
|
||||
'pulls.update',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a diplomatic close message with the configured limit', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||
pullRequests: createPullRequestPage({
|
||||
author: 'octo-contributor',
|
||||
numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||
}),
|
||||
});
|
||||
|
||||
await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ author: 'octo-contributor' }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
|
||||
assert.match(comment, /Thank you for your contribution/);
|
||||
assert.match(comment, /limit community contributors to 10 open pull requests/);
|
||||
assert.match(comment, /@octo-contributor/);
|
||||
assert.match(comment, /`pr-limit-exempt` label and reopen/);
|
||||
});
|
||||
|
||||
it('does not close an exempt PR when it is reopened', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ labels: ['PR-LIMIT-EXEMPT'] }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, false);
|
||||
assert.equal(result.exempt, true);
|
||||
assert.equal(result.openPrCount, null);
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
it('does not close Dependabot PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
pullRequests: createPullRequestPage({
|
||||
author: 'dependabot[bot]',
|
||||
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, false);
|
||||
assert.equal(result.dependabotExempt, true);
|
||||
assert.equal(result.openPrCount, null);
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
it('counts the current PR when the author has more than one page of open PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)],
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ number: 123 }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, true);
|
||||
assert.equal(result.openPrCount, 101);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# ruff:file-ignore[implicit-namespace-package, undocumented-public-class, undocumented-public-method]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "python_check_coverage.py"
|
||||
SPEC = importlib.util.spec_from_file_location("python_check_coverage", SCRIPT_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load {SCRIPT_PATH}")
|
||||
coverage_checker = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = coverage_checker
|
||||
SPEC.loader.exec_module(coverage_checker)
|
||||
|
||||
|
||||
class CoveragePolicyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.packages_dir = self.root / "packages"
|
||||
self.packages_dir.mkdir()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def write_package(self, directory: str, name: str, status: str) -> None:
|
||||
package_dir = self.packages_dir / directory
|
||||
package_dir.mkdir()
|
||||
(package_dir / "pyproject.toml").write_text(
|
||||
f"""
|
||||
[project]
|
||||
name = "{name}"
|
||||
classifiers = ["Development Status :: {status}"]
|
||||
""".strip()
|
||||
)
|
||||
|
||||
def write_coverage(self, files: dict[str, list[int]]) -> Path:
|
||||
classes = []
|
||||
total_lines = 0
|
||||
covered_lines = 0
|
||||
for file_path, hits in files.items():
|
||||
lines = []
|
||||
for line_number, hit_count in enumerate(hits, start=1):
|
||||
total_lines += 1
|
||||
covered_lines += hit_count > 0
|
||||
lines.append(f'<line number="{line_number}" hits="{hit_count}"/>')
|
||||
classes.append(f'<class filename="{file_path}"><lines>{"".join(lines)}</lines></class>')
|
||||
|
||||
line_rate = covered_lines / total_lines if total_lines else 0
|
||||
xml_path = self.root / "coverage.xml"
|
||||
xml_path.write_text(
|
||||
f"""
|
||||
<coverage line-rate="{line_rate}" branch-rate="0">
|
||||
<packages>
|
||||
<package name="test">
|
||||
<classes>{"".join(classes)}</classes>
|
||||
</package>
|
||||
</packages>
|
||||
</coverage>
|
||||
""".strip()
|
||||
)
|
||||
return xml_path
|
||||
|
||||
def test_load_package_policies_uses_lifecycle_exemptions(self) -> None:
|
||||
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
self.write_package("stable", "agent-framework-stable", "5 - Production/Stable")
|
||||
self.write_package("devui", "agent-framework-devui", "4 - Beta")
|
||||
self.write_package("lab", "agent-framework-lab", "4 - Beta")
|
||||
|
||||
policies = {policy.directory: policy for policy in coverage_checker.load_package_policies(self.packages_dir)}
|
||||
|
||||
self.assertFalse(policies["alpha"].enforced)
|
||||
self.assertTrue(policies["beta"].enforced)
|
||||
self.assertTrue(policies["stable"].enforced)
|
||||
self.assertTrue(policies["devui"].exempt)
|
||||
self.assertFalse(policies["devui"].enforced)
|
||||
self.assertTrue(policies["lab"].exempt)
|
||||
self.assertFalse(policies["lab"].enforced)
|
||||
|
||||
def test_load_package_policies_rejects_missing_lifecycle(self) -> None:
|
||||
package_dir = self.packages_dir / "missing"
|
||||
package_dir.mkdir()
|
||||
(package_dir / "pyproject.toml").write_text('[project]\nname = "agent-framework-missing"\n')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "exactly one Development Status"):
|
||||
coverage_checker.load_package_policies(self.packages_dir)
|
||||
|
||||
def test_parse_coverage_aggregates_nested_modules_by_distribution(self) -> None:
|
||||
xml_path = self.write_coverage({
|
||||
"packages/core/agent_framework/_agents.py": [1, 0],
|
||||
"packages/core/agent_framework/_workflows/_workflow.py": [1, 1],
|
||||
})
|
||||
|
||||
package_stats, _, _ = coverage_checker.parse_coverage_xml(xml_path)
|
||||
|
||||
self.assertEqual(package_stats["core"].lines_valid, 4)
|
||||
self.assertEqual(package_stats["core"].lines_covered, 3)
|
||||
|
||||
def test_beta_package_below_threshold_fails(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1, 0]})
|
||||
|
||||
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_missing_beta_package_fails(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({})
|
||||
|
||||
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_alpha_and_exempt_packages_do_not_fail(self) -> None:
|
||||
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
|
||||
self.write_package("devui", "agent-framework-devui", "4 - Beta")
|
||||
self.write_package("lab", "agent-framework-lab", "4 - Beta")
|
||||
xml_path = self.write_coverage({})
|
||||
|
||||
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_beta_package_at_threshold_passes(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1] * 17 + [0] * 3})
|
||||
|
||||
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,212 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for resolve_integration_test_target.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_resolve_integration_test_target.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const resolveIntegrationTestTarget = require('../scripts/resolve_integration_test_target.js');
|
||||
|
||||
const HEAD_SHA = 'a'.repeat(40);
|
||||
const BASE_SHA = 'b'.repeat(40);
|
||||
|
||||
function review({
|
||||
id,
|
||||
login,
|
||||
state = 'APPROVED',
|
||||
commitId = HEAD_SHA,
|
||||
submittedAt = `2026-07-13T00:00:${String(id).padStart(2, '0')}Z`,
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
commit_id: commitId,
|
||||
submitted_at: submittedAt,
|
||||
user: { login },
|
||||
};
|
||||
}
|
||||
|
||||
function createMocks({
|
||||
pullState = 'open',
|
||||
pullAuthor = 'contributor',
|
||||
reviews = [],
|
||||
permissions = {},
|
||||
} = {}) {
|
||||
const core = {
|
||||
infoMessages: [],
|
||||
info(message) {
|
||||
this.infoMessages.push(message);
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
repo: { owner: 'microsoft', repo: 'agent-framework' },
|
||||
};
|
||||
const github = {
|
||||
paginate: async () => reviews,
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
state: pullState,
|
||||
user: { login: pullAuthor },
|
||||
head: { sha: HEAD_SHA },
|
||||
base: { sha: BASE_SHA },
|
||||
},
|
||||
}),
|
||||
listReviews: async () => {},
|
||||
},
|
||||
repos: {
|
||||
get: async () => ({ data: { default_branch: 'main' } }),
|
||||
getBranch: async ({ branch }) => ({
|
||||
data: { commit: { sha: branch === 'main' ? BASE_SHA : HEAD_SHA } },
|
||||
}),
|
||||
getCollaboratorPermissionLevel: async ({ username }) => ({
|
||||
data: permissions[username] || {
|
||||
permission: 'read',
|
||||
user: { permissions: { push: false } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { core, context, github };
|
||||
}
|
||||
|
||||
const WRITE_PERMISSION = {
|
||||
permission: 'write',
|
||||
user: { permissions: { push: true } },
|
||||
};
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects missing and conflicting targets', async () => {
|
||||
const mocks = createMocks();
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget(mocks),
|
||||
/provide either a PR number or a branch name/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1', branch: 'feature' }),
|
||||
/not both/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid PR numbers and branch names', async () => {
|
||||
const mocks = createMocks();
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1;echo' }),
|
||||
/Invalid PR number/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, branch: 'feature branch' }),
|
||||
/Invalid branch name/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pull request resolution', () => {
|
||||
it('pins an open PR with two fresh write-capable approvals', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'maintainer-one' }),
|
||||
review({ id: 2, login: 'maintainer-two' }),
|
||||
],
|
||||
permissions: {
|
||||
'maintainer-one': WRITE_PERMISSION,
|
||||
'maintainer-two': WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
baseRef: BASE_SHA,
|
||||
checkoutRef: HEAD_SHA,
|
||||
description: 'PR #123',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects closed PRs', async () => {
|
||||
const mocks = createMocks({ pullState: 'closed' });
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/is not open/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale, self, and read-only approvals', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'stale', commitId: 'c'.repeat(40) }),
|
||||
review({ id: 2, login: 'contributor' }),
|
||||
review({ id: 3, login: 'reader' }),
|
||||
review({ id: 4, login: 'maintainer' }),
|
||||
],
|
||||
permissions: {
|
||||
contributor: WRITE_PERMISSION,
|
||||
reader: { permission: 'read', user: { permissions: { push: false } } },
|
||||
maintainer: WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/found 1/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses each reviewer latest decisive review and ignores later comments', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'changes-requested' }),
|
||||
review({ id: 2, login: 'changes-requested', state: 'CHANGES_REQUESTED' }),
|
||||
review({ id: 3, login: 'maintainer-one' }),
|
||||
review({ id: 4, login: 'maintainer-one', state: 'COMMENTED' }),
|
||||
review({ id: 5, login: 'maintainer-two' }),
|
||||
],
|
||||
permissions: {
|
||||
'changes-requested': WRITE_PERMISSION,
|
||||
'maintainer-one': WRITE_PERMISSION,
|
||||
'maintainer-two': WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
|
||||
assert.equal(result.checkoutRef, HEAD_SHA);
|
||||
});
|
||||
|
||||
it('does not count a dismissed approval', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'dismissed', state: 'DISMISSED' }),
|
||||
review({ id: 2, login: 'maintainer' }),
|
||||
],
|
||||
permissions: {
|
||||
dismissed: WRITE_PERMISSION,
|
||||
maintainer: WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/found 1/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch resolution', () => {
|
||||
it('pins base-repository branches and their comparison base to SHAs', async () => {
|
||||
const mocks = createMocks();
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, branch: 'feature/test' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
baseRef: BASE_SHA,
|
||||
checkoutRef: HEAD_SHA,
|
||||
description: 'branch feature/test',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for review_command.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_review_command.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const isReviewCommand = require('../scripts/review_command.js');
|
||||
|
||||
|
||||
describe('review command validation', () => {
|
||||
it('accepts the exact review command', () => {
|
||||
assert.equal(isReviewCommand('/review'), true);
|
||||
});
|
||||
|
||||
it('accepts surrounding whitespace', () => {
|
||||
assert.equal(isReviewCommand('/review\r\n'), true);
|
||||
assert.equal(isReviewCommand(' \n/review\t'), true);
|
||||
});
|
||||
|
||||
it('rejects commands with additional content', () => {
|
||||
assert.equal(isReviewCommand('/reviewer'), false);
|
||||
assert.equal(isReviewCommand('/review please'), false);
|
||||
assert.equal(isReviewCommand('/review\nadditional text'), false);
|
||||
assert.equal(isReviewCommand('/Review'), false);
|
||||
});
|
||||
|
||||
it('rejects missing or non-string comment bodies', () => {
|
||||
assert.equal(isReviewCommand(''), false);
|
||||
assert.equal(isReviewCommand(null), false);
|
||||
assert.equal(isReviewCommand(undefined), false);
|
||||
});
|
||||
});
|
||||
@@ -23,16 +23,16 @@ For each project that needs to be migrated, you need to do the following:
|
||||
- Identify the specific Semantic Kernel agent types being used:
|
||||
- `ChatCompletionAgent` → `ChatClientAgent`
|
||||
- `OpenAIAssistantAgent` → `assistantsClient.CreateAIAgent()` (via OpenAI Assistants client extension)
|
||||
- `AzureAIAgent` → `persistentAgentsClient.CreateAIAgent()` (via Microsoft Foundry client extension)
|
||||
- `AzureAIAgent` → `persistentAgentsClient.CreateAIAgent()` (via Azure AI Foundry client extension)
|
||||
- `OpenAIResponseAgent` → `responsesClient.CreateAIAgent()` (via OpenAI Responses client extension)
|
||||
- `A2AAgent` → `AIAgent` (via A2A card resolver)
|
||||
- `BedrockAgent` → Custom implementation required (not supported)
|
||||
- Determine if agents are being created new or retrieved from hosted services:
|
||||
- **New agents**: Use `CreateAIAgent()` methods
|
||||
- **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Microsoft Foundry
|
||||
- **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Azure AI Foundry
|
||||
</agent_type_identification>
|
||||
|
||||
- Determine the AI provider being used (OpenAI, Azure OpenAI, Microsoft Foundry, etc.)
|
||||
- Determine the AI provider being used (OpenAI, Azure OpenAI, Azure AI Foundry, etc.)
|
||||
- Analyze tool/function registration patterns
|
||||
- Review thread management and invocation patterns
|
||||
|
||||
@@ -90,7 +90,7 @@ below in wrong order or skip any of them):
|
||||
you generate report when migration complete. Report should contain:
|
||||
- all project dependencies changes (mention what was changed, added or removed, including provider-specific packages)
|
||||
- all code files that were changed (mention what was changed in the file, if it was not changed, just mention that the file was not changed)
|
||||
- provider-specific migration patterns used (OpenAI, Azure OpenAI, Microsoft Foundry, A2A, ONNX, etc.)
|
||||
- provider-specific migration patterns used (OpenAI, Azure OpenAI, Azure AI Foundry, A2A, ONNX, etc.)
|
||||
- all cases where you could not convert the code because of unsupported features and you were unable to find a workaround
|
||||
- unsupported providers that require custom implementation (Bedrock, CopilotStudio)
|
||||
- breaking glass pattern migrations (InnerContent → RawRepresentation) and any CodeInterpreter or advanced tool usage
|
||||
@@ -223,7 +223,7 @@ using Microsoft.Agents.AI;
|
||||
// Provider-specific namespaces (add only if needed):
|
||||
using OpenAI; // For OpenAI provider
|
||||
using Azure.AI.OpenAI; // For Azure OpenAI provider
|
||||
using Azure.AI.Agents.Persistent; // For Microsoft Foundry provider
|
||||
using Azure.AI.Agents.Persistent; // For Azure AI Foundry provider
|
||||
using Azure.Identity; // For Azure authentication
|
||||
```
|
||||
</configuration_changes>
|
||||
@@ -499,7 +499,7 @@ For every thread created if there's intent to cleanup, the caller should track a
|
||||
var assistantClient = new OpenAIClient(apiKey).GetAssistantClient();
|
||||
await assistantClient.DeleteThreadAsync(thread.ConversationId);
|
||||
|
||||
// For Microsoft Foundry (when cleanup is needed):
|
||||
// For Azure AI Foundry (when cleanup is needed):
|
||||
var persistentClient = new PersistentAgentsClient(endpoint, credential);
|
||||
await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId);
|
||||
|
||||
@@ -514,7 +514,7 @@ await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId);
|
||||
1. Remove `thread.DeleteAsync()` calls
|
||||
2. Use provider-specific client for cleanup when required
|
||||
3. Access thread ID via `thread.ConversationId` property
|
||||
4. Only implement cleanup for providers that require it (Assistants, Microsoft Foundry)
|
||||
4. Only implement cleanup for providers that require it (Assistants, Azure AI Foundry)
|
||||
</api_changes>
|
||||
|
||||
### Provider-Specific Creation Patterns
|
||||
@@ -550,13 +550,13 @@ AIAgent agent = new AzureOpenAIClient(endpoint, credential)
|
||||
.CreateAIAgent(instructions: instructions);
|
||||
```
|
||||
|
||||
**Microsoft Foundry (New):**
|
||||
**Azure AI Foundry (New):**
|
||||
```csharp
|
||||
AIAgent agent = new PersistentAgentsClient(endpoint, credential)
|
||||
.CreateAIAgent(model: deploymentName, instructions: instructions);
|
||||
```
|
||||
|
||||
**Microsoft Foundry (Existing):**
|
||||
**Azure AI Foundry (Existing):**
|
||||
```csharp
|
||||
AIAgent agent = await new PersistentAgentsClient(endpoint, credential)
|
||||
.GetAIAgentAsync(agentId);
|
||||
@@ -1079,7 +1079,7 @@ AgentThread thread = agent.GetNewThread();
|
||||
```
|
||||
</api_changes>
|
||||
|
||||
### 4. Microsoft Foundry (AzureAIAgent) Migration
|
||||
### 4. Azure AI Foundry (AzureAIAgent) Migration
|
||||
|
||||
<configuration_changes>
|
||||
**Remove Semantic Kernel Packages:**
|
||||
|
||||
@@ -32,13 +32,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@v4
|
||||
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@v4
|
||||
|
||||
# ℹ️ 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@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -18,12 +15,11 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }}
|
||||
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -31,46 +27,10 @@ env:
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
|
||||
|
||||
jobs:
|
||||
command_check:
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
(
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'OWNER'
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_review: ${{ steps.check.outputs.should_review }}
|
||||
steps:
|
||||
- name: Checkout review command validation
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
sparse-checkout: .github/scripts/review_command.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check review command
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const isReviewCommand = require('./.github/scripts/review_command.js');
|
||||
const shouldReview = context.eventName !== 'issue_comment' ||
|
||||
isReviewCommand(context.payload.comment?.body);
|
||||
core.setOutput('should_review', shouldReview ? 'true' : 'false');
|
||||
|
||||
team_check:
|
||||
needs: command_check
|
||||
if: ${{ needs.command_check.outputs.should_review == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
pr_number: ${{ steps.pr.outputs.pr_number }}
|
||||
@@ -82,7 +42,6 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
PR_HTML_URL: ${{ github.event.pull_request.html_url }}
|
||||
PR_NUMBER_COMMENT: ${{ github.event.issue.number }}
|
||||
PR_NUMBER_EVENT: ${{ github.event.pull_request.number }}
|
||||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
@@ -91,9 +50,6 @@ jobs:
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
pr_number="${PR_NUMBER_EVENT}"
|
||||
pr_url="${PR_HTML_URL}"
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "issue_comment" ]]; then
|
||||
pr_number="${PR_NUMBER_COMMENT}"
|
||||
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
|
||||
else
|
||||
pr_number="${PR_NUMBER_INPUT}"
|
||||
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
|
||||
@@ -108,78 +64,49 @@ jobs:
|
||||
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts/check_team_membership.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check review requester team membership
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
MEMBERSHIP_USER: ${{ github.event_name == 'issue_comment' && github.event.comment.user.login || '' }}
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.PR_NUMBER,
|
||||
username: process.env.MEMBERSHIP_USER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`User ${author} is a team member; proceeding with review.`);
|
||||
} else {
|
||||
core.info(`User ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
|
||||
let author = context.payload.pull_request?.user?.login;
|
||||
if (!author) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(process.env.PR_NUMBER),
|
||||
});
|
||||
author = pr.user.login;
|
||||
}
|
||||
|
||||
- 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
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
...context.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes',
|
||||
});
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
console.log(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
isTeamMember = false;
|
||||
}
|
||||
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; proceeding with review.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
|
||||
}
|
||||
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 60
|
||||
# Advisory check: failures here should not block the PR. The reviewer
|
||||
# posts comments as a best-effort signal; if the pipeline breaks, the
|
||||
@@ -189,7 +116,7 @@ jobs:
|
||||
steps:
|
||||
# Safe checkout: base repo only, not the untrusted PR head.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -198,7 +125,7 @@ jobs:
|
||||
|
||||
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -208,12 +135,12 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
@@ -226,8 +153,8 @@ jobs:
|
||||
id: review
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
PR_URL: ${{ needs.team_check.outputs.pr_url }}
|
||||
@@ -235,5 +162,4 @@ jobs:
|
||||
uv run python scripts/trigger_pr_review.py \
|
||||
--pr-url "$PR_URL" \
|
||||
--github-username "$GITHUB_ACTOR" \
|
||||
--review-compare \
|
||||
--no-require-comment-selection
|
||||
|
||||
@@ -37,12 +37,12 @@ jobs:
|
||||
outputs:
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||
azureStorageChanges: ${{ steps.filter.outputs.azurestorage }}
|
||||
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -50,12 +50,6 @@ jobs:
|
||||
- 'dotnet/**'
|
||||
cosmosdb:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
||||
azurestorage:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/**'
|
||||
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.IntegrationTests/**'
|
||||
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/**'
|
||||
- 'dotnet/Directory.Packages.props'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
# The Foundry hosted-agent IT is costly (builds a container, pushes to ACR,
|
||||
# provisions live agents). Only run it when the project under test, its
|
||||
# dependency chain, the test container, the test fixture, or their tooling
|
||||
@@ -72,6 +66,13 @@ jobs:
|
||||
- 'dotnet/Directory.Packages.props'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
functions:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.DurableTask/**'
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**'
|
||||
- 'dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/**'
|
||||
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/**'
|
||||
- '.github/actions/azure-functions-integration-setup/**'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
core:
|
||||
- 'dotnet/src/Microsoft.Agents.AI/**'
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
|
||||
@@ -110,7 +111,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -120,14 +121,10 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Build dotnet solutions
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -163,7 +160,6 @@ jobs:
|
||||
# Change to project directory to ensure local nuget.config is used
|
||||
pushd consoleapp
|
||||
dotnet add packcheck.csproj package Microsoft.Agents.AI --prerelease
|
||||
dotnet add packcheck.csproj package Microsoft.Agents.AI.LocalCodeAct --prerelease
|
||||
dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj
|
||||
|
||||
# Clean up
|
||||
@@ -185,20 +181,16 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
docs/specs
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||
@@ -209,29 +201,8 @@ jobs:
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Start Azurite Blob service
|
||||
if: ${{ runner.os == 'Linux' && (needs.paths-filter.outputs.azureStorageChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --detach --rm \
|
||||
--name azurite \
|
||||
--publish 10000:10000 \
|
||||
mcr.microsoft.com/azure-storage/azurite:3.35.0@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37 \
|
||||
azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --skipApiVersionCheck
|
||||
|
||||
for attempt in {1..30}; do
|
||||
if (echo > /dev/tcp/127.0.0.1/10000) > /dev/null 2>&1; then
|
||||
echo "AZURITE_AVAILABLE=true" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker logs azurite
|
||||
exit 1
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -264,6 +235,7 @@ jobs:
|
||||
-OutputPath dotnet/filtered-unit.slnx
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
|
||||
-TestProjectNameIncludeFilter "*IntegrationTests*" `
|
||||
-TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" `
|
||||
-OutputPath dotnet/filtered-integration.slnx
|
||||
|
||||
- name: Run Unit Tests
|
||||
@@ -299,7 +271,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@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -334,20 +306,19 @@ jobs:
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
|
||||
# Microsoft Foundry
|
||||
# Azure AI Foundry
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
# Anthropic Models
|
||||
# Disable Anthropic tests by not providing environment vars until 404 failure is resolved
|
||||
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
|
||||
# ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
|
||||
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
|
||||
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@2a82782178b2816d9d6960a7345fdd164791b323 # 5.5.3
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
|
||||
with:
|
||||
reports: "./TestResults/Coverage/**/*.cobertura.xml"
|
||||
targetdir: "./TestResults/Reports"
|
||||
@@ -355,7 +326,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report artifact
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
|
||||
path: ./TestResults/Reports # Directory containing files to upload
|
||||
@@ -367,7 +338,7 @@ jobs:
|
||||
|
||||
- name: Upload integration test results
|
||||
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
@@ -385,7 +356,7 @@ jobs:
|
||||
env:
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -394,11 +365,8 @@ jobs:
|
||||
dotnet
|
||||
python
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -413,7 +381,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@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -461,11 +429,110 @@ jobs:
|
||||
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
|
||||
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
|
||||
|
||||
# DurableTask and AzureFunctions integration tests (ubuntu/net10.0 only).
|
||||
# Split from main dotnet-test job for path-based filtering and parallelism.
|
||||
dotnet-test-functions:
|
||||
needs: [paths-filter]
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
(needs.paths-filter.outputs.functionsChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true' ||
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.
|
||||
.github
|
||||
dotnet
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Build functions integration test projects
|
||||
shell: bash
|
||||
working-directory: dotnet
|
||||
run: |
|
||||
dotnet build ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests -c Release -f net10.0 --warnaserror
|
||||
dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Set up Durable Task and Azure Functions Integration Test Emulators
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
id: azure-functions-setup
|
||||
|
||||
- name: Run Functions Integration Tests
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
run: |
|
||||
# Run DurableTask integration tests
|
||||
dotnet test `
|
||||
--project ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests `
|
||||
-f net10.0 `
|
||||
-c Release `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--report-junit `
|
||||
--results-directory ../IntegrationTestResults/ `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-not-trait "Category=IntegrationDisabled" `
|
||||
--parallel-algorithm aggressive `
|
||||
--max-threads 2.0x
|
||||
|
||||
# Run AzureFunctions integration tests
|
||||
dotnet test `
|
||||
--project ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests `
|
||||
-f net10.0 `
|
||||
-c Release `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--report-junit `
|
||||
--results-directory ../IntegrationTestResults/ `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-not-trait "Category=IntegrationDisabled" `
|
||||
--parallel-algorithm aggressive `
|
||||
--max-threads 2.0x
|
||||
env:
|
||||
# OpenAI Models
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_CHAT_MODEL_NAME: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_REASONING_MODEL_NAME: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
# Azure OpenAI Models
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
|
||||
# Azure AI Foundry
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
|
||||
- name: Upload functions test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-test-results-functions-net10.0-ubuntu-latest
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
if-no-files-found: ignore
|
||||
|
||||
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
|
||||
dotnet-build-and-test-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
|
||||
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions]
|
||||
steps:
|
||||
- name: Get Date
|
||||
shell: bash
|
||||
@@ -493,14 +560,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@v8
|
||||
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@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -512,13 +579,13 @@ jobs:
|
||||
github.event_name != 'pull_request' &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs: [dotnet-test]
|
||||
needs: [dotnet-test, dotnet-test-functions]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -530,12 +597,12 @@ jobs:
|
||||
python-version: "3.13"
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
@@ -552,13 +619,13 @@ jobs:
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
- name: Upload trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-integration-test-report
|
||||
path: |
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: jitterbit/get-changed-files@b17fbb00bdc0c0f63fcf166580804b4d2cdc2a42 # v1
|
||||
uses: jitterbit/get-changed-files@v1
|
||||
continue-on-error: true
|
||||
|
||||
- name: No C# files changed
|
||||
@@ -54,14 +54,11 @@ jobs:
|
||||
- name: Find csproj files
|
||||
id: find-csproj
|
||||
if: github.event_name != 'pull_request' || steps.changed-files.outputs.added_modified != '' || steps.changed-files.outcome == 'failure'
|
||||
env:
|
||||
ADDED_MODIFIED: ${{ steps.changed-files.outputs.added_modified }}
|
||||
run: |
|
||||
csproj_files=()
|
||||
exclude_files=("Experimental.Orchestration.Flow.csproj" "Experimental.Orchestration.Flow.UnitTests.csproj" "Experimental.Orchestration.Flow.IntegrationTests.csproj")
|
||||
set -f
|
||||
if [[ ${{ steps.changed-files.outcome }} == 'success' ]]; then
|
||||
for file in $ADDED_MODIFIED; do
|
||||
for file in ${{ steps.changed-files.outputs.added_modified }}; do
|
||||
echo "$file was changed"
|
||||
dir="./$file"
|
||||
while [[ $dir != "." && $dir != "/" && $dir != $GITHUB_WORKSPACE ]]; do
|
||||
@@ -83,7 +80,6 @@ jobs:
|
||||
csproj_files=($(printf "%s\n" "${csproj_files[@]}" | sort -u))
|
||||
echo "Found ${#csproj_files[@]} unique csproj/slnx files: ${csproj_files[*]}"
|
||||
echo "csproj_files=${csproj_files[*]}" >> $GITHUB_OUTPUT
|
||||
set +f
|
||||
|
||||
- name: Pull container dotnet/sdk:${{ matrix.dotnet }}
|
||||
if: steps.find-csproj.outputs.csproj_files != ''
|
||||
@@ -92,11 +88,8 @@ jobs:
|
||||
# This step will run dotnet format on each of the unique csproj files and fail if any changes are made
|
||||
- name: Run dotnet format
|
||||
if: steps.find-csproj.outputs.csproj_files != ''
|
||||
env:
|
||||
CSPROJ_FILES: ${{ steps.find-csproj.outputs.csproj_files }}
|
||||
run: |
|
||||
set -f
|
||||
for csproj in $CSPROJ_FILES; do
|
||||
for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do
|
||||
echo "Running dotnet format on $csproj"
|
||||
docker run --rm -v "$(pwd):/app" -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} dotnet format "$csproj" --verify-no-changes --verbosity diagnostic
|
||||
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic"
|
||||
done
|
||||
|
||||
@@ -9,30 +9,16 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Immutable commit SHA to check out"
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TENANT_ID:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZUREAI__ENDPOINT:
|
||||
required: true
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
dotnet-integration-tests:
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -43,7 +29,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -64,7 +50,7 @@ jobs:
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
@@ -77,12 +63,16 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Set up Durable Task and Azure Functions Integration Test Emulators
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
|
||||
- name: Run Integration Tests
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -98,7 +88,6 @@ jobs:
|
||||
env:
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
|
||||
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
environment: 'integration'
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
declarative-agents
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@v5.2.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -105,13 +105,10 @@ jobs:
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
|
||||
# Microsoft Foundry
|
||||
# Azure AI Foundry
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
# Foundry
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
|
||||
- name: Write Job Summary
|
||||
if: always()
|
||||
@@ -126,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: verify-samples-results
|
||||
path: |
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
name: GitHub automation tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: node --test .github/tests/*.js
|
||||
|
||||
- name: Run Python tests
|
||||
run: python .github/tests/test_python_check_coverage.py
|
||||
@@ -3,7 +3,7 @@
|
||||
# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name.
|
||||
#
|
||||
# It calls dedicated integration-only workflows (dotnet-integration-tests and python-integration-tests),
|
||||
# passing an immutable commit SHA so they check out and test the approved code.
|
||||
# passing a ref so they check out and test the correct code.
|
||||
# Changed paths are detected here so only the relevant test suites run.
|
||||
#
|
||||
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
|
||||
@@ -37,50 +38,67 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
base-ref: ${{ steps.resolve.outputs.base-ref }}
|
||||
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
|
||||
python-changes: ${{ steps.detect-changes.outputs.python }}
|
||||
steps:
|
||||
- name: Check out trusted workflow helpers
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Resolve and authorize checkout ref
|
||||
- name: Resolve checkout ref
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const resolveIntegrationTestTarget = require(
|
||||
'./.github/scripts/resolve_integration_test_target.js'
|
||||
);
|
||||
const target = await resolveIntegrationTestTarget({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber: process.env.PR_NUMBER,
|
||||
branch: process.env.BRANCH,
|
||||
});
|
||||
core.setOutput('checkout-ref', target.checkoutRef);
|
||||
core.setOutput('base-ref', target.baseRef);
|
||||
core.info(`Running integration tests for ${target.description} at ${target.checkoutRef}.`);
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name, not both."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
|
||||
echo "::error::Invalid PR number. Only numeric values are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
|
||||
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
|
||||
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for PR #$PR_NUMBER"
|
||||
else
|
||||
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
|
||||
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for branch $BRANCH"
|
||||
fi
|
||||
|
||||
- name: Detect changed paths
|
||||
id: detect-changes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BASE_REF: ${{ steps.resolve.outputs.base-ref }}
|
||||
CHECKOUT_REF: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/$BASE_REF...$CHECKOUT_REF" \
|
||||
--jq '.files[].filename')
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
|
||||
else
|
||||
# For branches, compare against main using the GitHub API
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
|
||||
fi
|
||||
|
||||
DOTNET_CHANGES=false
|
||||
PYTHON_CHANGES=false
|
||||
@@ -95,41 +113,22 @@ jobs:
|
||||
|
||||
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected changes; dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
echo "Detected changes — dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
|
||||
dotnet-integration-tests:
|
||||
name: .NET Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/dotnet-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZUREAI__ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
secrets: inherit
|
||||
|
||||
python-integration-tests:
|
||||
name: Python Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.python-changes == 'true'
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/python-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -2,13 +2,7 @@ name: Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, typed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: Issue number to triage
|
||||
required: true
|
||||
type: string
|
||||
types: [opened, labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,8 +12,9 @@ permissions:
|
||||
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.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug'))
|
||||
|| (github.event.action == 'labeled' && github.event.label.name == 'bug'))
|
||||
&& github.event.issue.number
|
||||
|| github.run_id
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
@@ -29,17 +24,11 @@ env:
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| github.event.issue.type.name == 'Bug'
|
||||
}}
|
||||
if: ${{ (github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'bug') }}
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||
@@ -49,18 +38,14 @@ jobs:
|
||||
id: issue
|
||||
shell: bash
|
||||
env:
|
||||
ISSUE_NUMBER: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' && inputs.issue_number
|
||||
|| github.event.issue.number
|
||||
}}
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
issue_number="${ISSUE_NUMBER}"
|
||||
issue_number="${ISSUE_NUMBER_EVENT}"
|
||||
|
||||
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Could not determine issue number from event payload or manual input." >&2
|
||||
echo "Could not determine issue number from event payload." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -68,38 +53,20 @@ jobs:
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- 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@v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -119,23 +86,14 @@ jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| needs.team_check.outputs.is_team_member == 'false'
|
||||
}}
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -143,7 +101,7 @@ jobs:
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
@@ -153,12 +111,12 @@ jobs:
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
@@ -168,7 +126,7 @@ jobs:
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -178,8 +136,7 @@ jobs:
|
||||
id: spam
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
@@ -192,18 +149,21 @@ jobs:
|
||||
--apply-labels
|
||||
|
||||
- name: Stop after spam gate
|
||||
if: ${{ steps.spam.outputs.allow_triage != 'true' }}
|
||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
||||
shell: bash
|
||||
env:
|
||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
||||
run: |
|
||||
echo "Stopping: issue triage preflight did not allow automation."
|
||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
||||
exit 1
|
||||
|
||||
- name: Reproduce reported issue
|
||||
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
|
||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
# Not seen by the agent prompt; used only to push a paper-trail
|
||||
# branch back to maf-dashboard at run end.
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
|
||||
@@ -10,39 +10,12 @@ jobs:
|
||||
name: "Issue: add labels"
|
||||
if: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/github-script@v8
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts/check_team_membership.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
// Get the issue body and title
|
||||
const body = context.payload.issue.body
|
||||
@@ -51,14 +24,21 @@ jobs:
|
||||
// Define the labels array
|
||||
let labels = []
|
||||
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js')
|
||||
const { isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: context.issue.number,
|
||||
})
|
||||
// Check if the issue author is in the agentframework-developers team
|
||||
let isTeamMember = false
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: context.payload.issue.user.login
|
||||
})
|
||||
console.log("Team Membership Data:", teamMembership);
|
||||
isTeamMember = teamMembership.data.state === 'active'
|
||||
} catch (error) {
|
||||
// User is not in the team or team doesn't exist
|
||||
console.error("Error fetching team membership:", error);
|
||||
isTeamMember = false
|
||||
}
|
||||
|
||||
// Only add triage label if the author is not in the team
|
||||
if (!isTeamMember) {
|
||||
@@ -110,7 +90,9 @@ jobs:
|
||||
// Check for issue type from issue form dropdown
|
||||
const issueTypeField = getFormFieldValue(body, 'Type of Issue')
|
||||
if (issueTypeField) {
|
||||
if (issueTypeField === 'Feature Request') {
|
||||
if (issueTypeField === 'Bug') {
|
||||
labels.push("bug")
|
||||
} else if (issueTypeField === 'Feature Request') {
|
||||
labels.push("enhancement")
|
||||
} else if (issueTypeField === 'Question') {
|
||||
labels.push("question")
|
||||
|
||||
@@ -6,54 +6,16 @@
|
||||
# https://github.com/actions/labeler
|
||||
|
||||
name: Label pull request
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
on: [pull_request_target]
|
||||
|
||||
jobs:
|
||||
add_label:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.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
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const { syncBreakingChangeLabelFromTitle } = require('./.github/scripts/title_prefix.js');
|
||||
await syncBreakingChangeLabelFromTitle({ github, context, core });
|
||||
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
|
||||
|
||||
@@ -15,17 +15,58 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
- uses: actions/github-script@v8
|
||||
name: "Issue/PR: update title"
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { updateTitleForAddedLabel } = require('./.github/scripts/title_prefix.js');
|
||||
await updateTitleForAddedLabel({ github, context, core });
|
||||
let prefixLabels = {
|
||||
"python": "Python",
|
||||
".NET": ".NET"
|
||||
};
|
||||
|
||||
function addTitlePrefix(title, prefix)
|
||||
{
|
||||
// Update the title based on the label and prefix
|
||||
// Check if the title starts with the prefix (case-sensitive)
|
||||
if (!title.startsWith(prefix + ": ")) {
|
||||
// If not, check if the first word is the label (case-insensitive)
|
||||
if (title.match(new RegExp(`^${prefix}`, 'i'))) {
|
||||
// If yes, replace it with the prefix (case-sensitive)
|
||||
title = title.replace(new RegExp(`^${prefix}`, 'i'), prefix);
|
||||
} else {
|
||||
// If not, prepend the prefix to the title
|
||||
title = prefix + ": " + title;
|
||||
}
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
labelAdded = context.payload.label.name
|
||||
|
||||
// Check if the issue or PR has the label
|
||||
if (labelAdded in prefixLabels) {
|
||||
let prefix = prefixLabels[labelAdded];
|
||||
switch(context.eventName) {
|
||||
case 'issues':
|
||||
github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: addTitlePrefix(context.payload.issue.title, prefix)
|
||||
});
|
||||
break
|
||||
|
||||
case 'pull_request_target':
|
||||
github.rest.pulls.update({
|
||||
pull_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: addTitlePrefix(context.payload.pull_request.title, prefix)
|
||||
});
|
||||
break
|
||||
default:
|
||||
core.setFailed('Unrecognited eventName: ' + context.eventName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
name: Limit community pull requests
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-limit-${{ github.repository }}-${{ github.event.pull_request.user.login }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
MAX_OPEN_PULL_REQUESTS: '10'
|
||||
PR_LIMIT_EXEMPT_LABEL: pr-limit-exempt
|
||||
TOO_MANY_PRS_LABEL: too-many-prs
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.PR_NUMBER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; skipping open PR limit.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a team member; checking open PR limit.`);
|
||||
}
|
||||
|
||||
limit_open_prs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Enforce open PR limit
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
|
||||
await enforcePrLimit({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
exemptLabelName: process.env.PR_LIMIT_EXEMPT_LABEL,
|
||||
maxOpenPrs: Number.parseInt(process.env.MAX_OPEN_PULL_REQUESTS, 10),
|
||||
labelName: process.env.TOO_MANY_PRS_LABEL,
|
||||
});
|
||||
@@ -19,21 +19,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
# check out the latest version of the code
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Chrome for Puppeteer
|
||||
run: npx puppeteer browsers install chrome
|
||||
|
||||
# Checks the status of hyperlinks in all files
|
||||
- name: Run linkspector
|
||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1.5.1
|
||||
uses: umbrelladocs/action-linkspector@v1
|
||||
with:
|
||||
reporter: local
|
||||
filter_mode: nofilter
|
||||
|
||||
@@ -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@v8
|
||||
env:
|
||||
TIMEOUT_SECONDS: "3600"
|
||||
INTERVAL_SECONDS: "30"
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
|
||||
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
|
||||
# They are outside our control and their transient failures should not block merges.
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review"
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
|
||||
with:
|
||||
script: |
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Check Python test coverage against threshold for enforced targets.
|
||||
|
||||
This script parses a Cobertura XML coverage report and enforces a minimum
|
||||
coverage threshold on specific targets. Targets can be package names
|
||||
(e.g., "packages.core.agent_framework") or individual Python file paths
|
||||
(e.g., "packages/core/agent_framework/observability.py").
|
||||
|
||||
Non-enforced targets are reported for visibility but don't block the build.
|
||||
|
||||
Usage:
|
||||
python python-check-coverage.py <coverage-xml-path> <threshold>
|
||||
|
||||
Example:
|
||||
python python-check-coverage.py python-coverage.xml 85
|
||||
"""
|
||||
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
|
||||
# =============================================================================
|
||||
# ENFORCED TARGETS CONFIGURATION
|
||||
# =============================================================================
|
||||
# Add or remove entries from this set to control which targets must meet
|
||||
# the coverage threshold. Only these targets will fail the build if below
|
||||
# threshold. Other targets are reported for visibility only.
|
||||
#
|
||||
# Target values can be:
|
||||
# - Package paths as they appear in the coverage report
|
||||
# (e.g., "packages.azure-ai.agent_framework_azure_ai")
|
||||
# - Python source file paths as they appear in the coverage report
|
||||
# (e.g., "packages/core/agent_framework/observability.py")
|
||||
# =============================================================================
|
||||
ENFORCED_TARGETS: set[str] = {
|
||||
# Packages (sorted alphabetically)
|
||||
"packages.anthropic.agent_framework_anthropic",
|
||||
"packages.azure-ai-search.agent_framework_azure_ai_search",
|
||||
"packages.core.agent_framework",
|
||||
"packages.core.agent_framework._workflows",
|
||||
"packages.foundry.agent_framework_foundry",
|
||||
"packages.openai.agent_framework_openai",
|
||||
"packages.purview.agent_framework_purview",
|
||||
# Individual files (if you want to enforce specific files instead of whole packages)
|
||||
"packages/core/agent_framework/observability.py",
|
||||
# Add more targets here as coverage improves
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageCoverage:
|
||||
"""Coverage data for a single package."""
|
||||
|
||||
name: str
|
||||
line_rate: float
|
||||
branch_rate: float
|
||||
lines_valid: int
|
||||
lines_covered: int
|
||||
branches_valid: int
|
||||
branches_covered: int
|
||||
|
||||
@property
|
||||
def line_coverage_percent(self) -> float:
|
||||
"""Return line coverage as a percentage."""
|
||||
return self.line_rate * 100
|
||||
|
||||
@property
|
||||
def branch_coverage_percent(self) -> float:
|
||||
"""Return branch coverage as a percentage."""
|
||||
return self.branch_rate * 100
|
||||
|
||||
|
||||
def normalize_coverage_path(path: str) -> str:
|
||||
"""Normalize coverage paths for reliable matching."""
|
||||
return path.replace("\\", "/").lstrip("./")
|
||||
|
||||
|
||||
def parse_coverage_xml(
|
||||
xml_path: str,
|
||||
) -> tuple[dict[str, PackageCoverage], dict[str, PackageCoverage], float, float]:
|
||||
"""Parse Cobertura XML and extract per-package coverage data.
|
||||
|
||||
Args:
|
||||
xml_path: Path to the Cobertura XML coverage report.
|
||||
|
||||
Returns:
|
||||
A tuple of (packages_dict, files_dict, overall_line_rate, overall_branch_rate).
|
||||
"""
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Get overall coverage from root element
|
||||
overall_line_rate = float(root.get("line-rate", 0))
|
||||
overall_branch_rate = float(root.get("branch-rate", 0))
|
||||
|
||||
packages: dict[str, PackageCoverage] = {}
|
||||
file_stats: dict[str, dict[str, int]] = {}
|
||||
|
||||
for package in root.findall(".//package"):
|
||||
package_path = package.get("name", "unknown")
|
||||
|
||||
line_rate = float(package.get("line-rate", 0))
|
||||
branch_rate = float(package.get("branch-rate", 0))
|
||||
|
||||
# Count lines and branches from classes within this package
|
||||
lines_valid = 0
|
||||
lines_covered = 0
|
||||
branches_valid = 0
|
||||
branches_covered = 0
|
||||
|
||||
for class_elem in package.findall(".//class"):
|
||||
file_path = normalize_coverage_path(class_elem.get("filename", ""))
|
||||
if file_path and file_path not in file_stats:
|
||||
file_stats[file_path] = {
|
||||
"lines_valid": 0,
|
||||
"lines_covered": 0,
|
||||
"branches_valid": 0,
|
||||
"branches_covered": 0,
|
||||
}
|
||||
|
||||
for line in class_elem.findall(".//line"):
|
||||
lines_valid += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
lines_covered += 1
|
||||
|
||||
if file_path:
|
||||
file_stats[file_path]["lines_valid"] += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
file_stats[file_path]["lines_covered"] += 1
|
||||
|
||||
# Branch coverage from line elements
|
||||
if line.get("branch") == "true":
|
||||
condition_coverage = line.get("condition-coverage", "")
|
||||
if condition_coverage:
|
||||
# Parse "X% (covered/total)" format
|
||||
try:
|
||||
coverage_parts = (
|
||||
condition_coverage.split("(")[1].rstrip(")").split("/")
|
||||
)
|
||||
branches_covered += int(coverage_parts[0])
|
||||
branches_valid += int(coverage_parts[1])
|
||||
if file_path:
|
||||
file_stats[file_path]["branches_covered"] += int(
|
||||
coverage_parts[0]
|
||||
)
|
||||
file_stats[file_path]["branches_valid"] += int(
|
||||
coverage_parts[1]
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
# Ignore malformed condition-coverage strings; treat this line as having no branch data.
|
||||
pass
|
||||
|
||||
# Use full package path as the key (no aggregation)
|
||||
packages[package_path] = PackageCoverage(
|
||||
name=package_path,
|
||||
line_rate=line_rate if lines_valid == 0 else lines_covered / lines_valid,
|
||||
branch_rate=branch_rate
|
||||
if branches_valid == 0
|
||||
else branches_covered / branches_valid,
|
||||
lines_valid=lines_valid,
|
||||
lines_covered=lines_covered,
|
||||
branches_valid=branches_valid,
|
||||
branches_covered=branches_covered,
|
||||
)
|
||||
|
||||
files: dict[str, PackageCoverage] = {}
|
||||
for file_path, stats in file_stats.items():
|
||||
lines_valid = stats["lines_valid"]
|
||||
lines_covered = stats["lines_covered"]
|
||||
branches_valid = stats["branches_valid"]
|
||||
branches_covered = stats["branches_covered"]
|
||||
|
||||
files[file_path] = PackageCoverage(
|
||||
name=file_path,
|
||||
line_rate=0 if lines_valid == 0 else lines_covered / lines_valid,
|
||||
branch_rate=0 if branches_valid == 0 else branches_covered / branches_valid,
|
||||
lines_valid=lines_valid,
|
||||
lines_covered=lines_covered,
|
||||
branches_valid=branches_valid,
|
||||
branches_covered=branches_covered,
|
||||
)
|
||||
|
||||
return packages, files, overall_line_rate, overall_branch_rate
|
||||
|
||||
|
||||
def format_coverage_value(coverage: float, threshold: float, is_enforced: bool) -> str:
|
||||
"""Format a coverage value with optional pass/fail indicator.
|
||||
|
||||
Args:
|
||||
coverage: Coverage percentage (0-100).
|
||||
threshold: Minimum required coverage percentage.
|
||||
is_enforced: Whether this target is enforced.
|
||||
|
||||
Returns:
|
||||
Formatted string like "85.5%" or "85.5% ✅" or "75.0% ❌".
|
||||
"""
|
||||
formatted = f"{coverage:.1f}%"
|
||||
if is_enforced:
|
||||
icon = "✅" if coverage >= threshold else "❌"
|
||||
formatted = f"{formatted} {icon}"
|
||||
return formatted
|
||||
|
||||
|
||||
def print_coverage_table(
|
||||
packages: dict[str, PackageCoverage],
|
||||
files: dict[str, PackageCoverage],
|
||||
threshold: float,
|
||||
overall_line_rate: float,
|
||||
overall_branch_rate: float,
|
||||
) -> None:
|
||||
"""Print a formatted coverage summary table.
|
||||
|
||||
Args:
|
||||
packages: Dictionary of package name to coverage data.
|
||||
files: Dictionary of file path to coverage data, used for per-file enforcement.
|
||||
threshold: Minimum required coverage percentage.
|
||||
overall_line_rate: Overall line coverage rate (0-1).
|
||||
overall_branch_rate: Overall branch coverage rate (0-1).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("PYTHON TEST COVERAGE REPORT")
|
||||
print("=" * 80)
|
||||
|
||||
# Overall coverage
|
||||
print(f"\nOverall Line Coverage: {overall_line_rate * 100:.1f}%")
|
||||
print(f"Overall Branch Coverage: {overall_branch_rate * 100:.1f}%")
|
||||
print(f"Threshold: {threshold}%")
|
||||
|
||||
enforced_targets = {normalize_coverage_path(t) for t in ENFORCED_TARGETS}
|
||||
|
||||
# Package table
|
||||
print("\n" + "-" * 110)
|
||||
print(f"{'Package':<80} {'Lines':<15} {'Line Cov':<15}")
|
||||
print("-" * 110)
|
||||
|
||||
# Sort: enforced package targets first, then alphabetically
|
||||
sorted_packages = sorted(
|
||||
packages.values(),
|
||||
key=lambda p: (p.name not in ENFORCED_TARGETS, p.name),
|
||||
)
|
||||
|
||||
for pkg in sorted_packages:
|
||||
is_enforced = normalize_coverage_path(pkg.name) in enforced_targets
|
||||
enforced_marker = "[ENFORCED] " if is_enforced else ""
|
||||
line_cov = format_coverage_value(
|
||||
pkg.line_coverage_percent, threshold, is_enforced
|
||||
)
|
||||
lines_info = f"{pkg.lines_covered}/{pkg.lines_valid}"
|
||||
package_label = f"{enforced_marker}{pkg.name}"
|
||||
|
||||
print(f"{package_label:<80} {lines_info:<15} {line_cov:<15}")
|
||||
|
||||
print("-" * 110)
|
||||
|
||||
# Enforced file/model entries (if configured)
|
||||
enforced_files = [
|
||||
files[target]
|
||||
for target in sorted(enforced_targets)
|
||||
if target in files and target.endswith(".py")
|
||||
]
|
||||
|
||||
if enforced_files:
|
||||
print("\nEnforced Files/Models")
|
||||
print("-" * 110)
|
||||
print(f"{'File':<80} {'Lines':<15} {'Line Cov':<15}")
|
||||
print("-" * 110)
|
||||
|
||||
for file_cov in enforced_files:
|
||||
line_cov = format_coverage_value(
|
||||
file_cov.line_coverage_percent, threshold, True
|
||||
)
|
||||
lines_info = f"{file_cov.lines_covered}/{file_cov.lines_valid}"
|
||||
print(f"[ENFORCED] {file_cov.name:<69} {lines_info:<15} {line_cov:<15}")
|
||||
|
||||
print("-" * 110)
|
||||
|
||||
|
||||
def check_coverage(xml_path: str, threshold: float) -> bool:
|
||||
"""Check if all enforced targets meet the coverage threshold.
|
||||
|
||||
Args:
|
||||
xml_path: Path to the Cobertura XML coverage report.
|
||||
threshold: Minimum required coverage percentage.
|
||||
|
||||
Returns:
|
||||
True if all enforced targets pass, False otherwise.
|
||||
"""
|
||||
packages, files, overall_line_rate, overall_branch_rate = parse_coverage_xml(
|
||||
xml_path
|
||||
)
|
||||
|
||||
print_coverage_table(
|
||||
packages, files, threshold, overall_line_rate, overall_branch_rate
|
||||
)
|
||||
|
||||
# Check enforced targets
|
||||
failed_targets: list[str] = []
|
||||
missing_targets: list[str] = []
|
||||
|
||||
for target_name in ENFORCED_TARGETS:
|
||||
normalized_target = normalize_coverage_path(target_name)
|
||||
package_alias = normalized_target.replace("/", ".")
|
||||
|
||||
target_coverage = None
|
||||
if target_name in packages:
|
||||
target_coverage = packages[target_name]
|
||||
elif normalized_target in files:
|
||||
target_coverage = files[normalized_target]
|
||||
elif package_alias in packages:
|
||||
target_coverage = packages[package_alias]
|
||||
|
||||
if target_coverage is None:
|
||||
missing_targets.append(target_name)
|
||||
continue
|
||||
|
||||
if target_coverage.line_coverage_percent < threshold:
|
||||
failed_targets.append(
|
||||
f"{target_name} ({target_coverage.line_coverage_percent:.1f}%)"
|
||||
)
|
||||
|
||||
# Report results
|
||||
if missing_targets:
|
||||
print(
|
||||
f"\n❌ FAILED: Enforced targets not found in coverage report: {', '.join(missing_targets)}"
|
||||
)
|
||||
return False
|
||||
|
||||
if failed_targets:
|
||||
print(
|
||||
f"\n❌ FAILED: The following enforced targets are below {threshold}% coverage threshold:"
|
||||
)
|
||||
for target in failed_targets:
|
||||
print(f" - {target}")
|
||||
print("\nTo fix: Add more tests to improve coverage for the failing targets.")
|
||||
return False
|
||||
|
||||
if ENFORCED_TARGETS:
|
||||
found_enforced = [
|
||||
target
|
||||
for target in ENFORCED_TARGETS
|
||||
if target in packages or normalize_coverage_path(target) in files
|
||||
]
|
||||
if found_enforced:
|
||||
print(
|
||||
f"\n✅ PASSED: All enforced targets meet the {threshold}% coverage threshold."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Returns:
|
||||
Exit code: 0 for success, 1 for failure.
|
||||
"""
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
|
||||
print(f"Example: {sys.argv[0]} python-coverage.xml 85")
|
||||
return 1
|
||||
|
||||
xml_path = sys.argv[1]
|
||||
try:
|
||||
threshold = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Error: Invalid threshold value: {sys.argv[2]}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
success = check_coverage(xml_path, threshold)
|
||||
return 0 if success else 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Coverage file not found: {xml_path}")
|
||||
return 1
|
||||
except ET.ParseError as e:
|
||||
print(f"Error: Failed to parse coverage XML: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,10 +6,6 @@ on:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "python/**"
|
||||
- "!python/AGENTS.md"
|
||||
- "!python/**/AGENTS.md"
|
||||
- "!python/.github/skills/*"
|
||||
- "!python/.github/skills/**"
|
||||
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
@@ -31,7 +27,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -42,11 +38,11 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/prek
|
||||
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
|
||||
- uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
- uses: j178/prek-action@v1
|
||||
name: Run Pre-commit Hooks (excluding poe-check)
|
||||
env:
|
||||
SKIP: poe-check
|
||||
@@ -68,7 +64,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -97,7 +93,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -113,8 +109,8 @@ jobs:
|
||||
- name: Run markdown code lint
|
||||
run: uv run poe markdown-code-lint
|
||||
|
||||
test-typing:
|
||||
name: Test Typing Checks
|
||||
mypy:
|
||||
name: Mypy Checks
|
||||
if: "!cancelled()"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -128,7 +124,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -139,5 +135,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run tests/samples type checkers (mypy, pyrefly, ty)
|
||||
run: uv run python scripts/workspace_poe_tasks.py ci-test-typing
|
||||
- name: Run Mypy
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
|
||||
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
name: Python - Dependency Maintenance
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: python-dependency-maintenance
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
dependency-maintenance:
|
||||
name: Dependency Maintenance
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Match the existing Python dependency maintenance workflows. Reevaluate if package
|
||||
# installability starts differing across supported Python versions.
|
||||
UV_PYTHON: "3.13"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Set dependency release cutoff
|
||||
run: |
|
||||
cutoff="$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "DEPENDENCY_RELEASE_CUTOFF=${cutoff}" >> "$GITHUB_ENV"
|
||||
echo "Using dependency release cutoff: ${cutoff}"
|
||||
|
||||
- name: Repin dev dependency declarations
|
||||
run: uv run poe upgrade-dev-dependency-pins
|
||||
working-directory: ./python
|
||||
|
||||
- name: Refresh lockfile after dev pin updates
|
||||
run: uv lock
|
||||
working-directory: ./python
|
||||
|
||||
- name: Save dev dependency changes
|
||||
run: |
|
||||
DEV_PATCH="${RUNNER_TEMP}/python-dev-dependency-updates.patch"
|
||||
git diff -- python/pyproject.toml "python/packages/*/pyproject.toml" python/uv.lock > "${DEV_PATCH}"
|
||||
if [ -s "${DEV_PATCH}" ]; then
|
||||
echo "has_dev_changes=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_dev_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "patch=${DEV_PATCH}" >> "$GITHUB_OUTPUT"
|
||||
id: dev_changes
|
||||
|
||||
- name: Run dependency bounds test scenarios
|
||||
id: validate_bounds_test
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-test --package "*"
|
||||
working-directory: ./python
|
||||
|
||||
- name: Run dependency upper-bound validation
|
||||
id: validate_ranges
|
||||
if: steps.validate_bounds_test.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
working-directory: ./python
|
||||
|
||||
- name: Upload dependency validation reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dependency-maintenance-results
|
||||
path: |
|
||||
python/scripts/dependencies/dependency-bounds-test-results.json
|
||||
python/scripts/dependencies/dependency-range-results.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create issue for failed dependency bounds test
|
||||
if: steps.validate_bounds_test.outcome != 'success'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const reportPath = "python/scripts/dependencies/dependency-bounds-test-results.json"
|
||||
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
})
|
||||
const openIssueTitles = new Set(
|
||||
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
|
||||
)
|
||||
|
||||
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
|
||||
const title = "Dependency bounds test failed"
|
||||
if (openIssueTitles.has(title)) {
|
||||
core.info(`Issue already exists: ${title}`)
|
||||
return
|
||||
}
|
||||
|
||||
const bodyLines = [
|
||||
"Automated dependency bounds test mode failed before dependency upper-bound validation could run.",
|
||||
"",
|
||||
"The weekly dependency maintenance workflow kept only dev dependency updates for the generated PR, if any, and skipped dependency range updates for this run.",
|
||||
"",
|
||||
]
|
||||
|
||||
if (fs.existsSync(reportPath)) {
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
||||
const failedScenarios = (report.scenarios ?? []).filter((scenario) => scenario.status === "failed")
|
||||
for (const scenario of failedScenarios) {
|
||||
bodyLines.push(`### ${scenario.name} scenario (${scenario.resolution})`)
|
||||
const failedPackages = (scenario.packages ?? []).filter((pkg) => pkg.status === "failed")
|
||||
for (const pkg of failedPackages.slice(0, 10)) {
|
||||
bodyLines.push(
|
||||
"",
|
||||
`- Package: \`${pkg.package_name}\``,
|
||||
`- Project path: \`${pkg.project_path}\``,
|
||||
"",
|
||||
"```",
|
||||
formatError(pkg.error).slice(0, 3500),
|
||||
"```"
|
||||
)
|
||||
}
|
||||
if (failedPackages.length > 10) {
|
||||
bodyLines.push("", `_Additional failed packages omitted: ${failedPackages.length - 10}_`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bodyLines.push(`No dependency bounds test report was found at \`${reportPath}\`.`)
|
||||
}
|
||||
|
||||
bodyLines.push("", `Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`)
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body: bodyLines.join("\n"),
|
||||
})
|
||||
core.info(`Created issue: ${title}`)
|
||||
|
||||
- name: Create issues for failed dependency candidates
|
||||
if: always()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
core.info(`No dependency range report found at ${reportPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
||||
const dependencyFailures = []
|
||||
|
||||
for (const packageResult of report.packages ?? []) {
|
||||
for (const dependency of packageResult.dependencies ?? []) {
|
||||
const candidateVersions = new Set(dependency.candidate_versions ?? [])
|
||||
const failedAttempts = (dependency.attempts ?? []).filter(
|
||||
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
|
||||
)
|
||||
if (!failedAttempts.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const failuresByVersion = new Map()
|
||||
for (const attempt of failedAttempts) {
|
||||
const version = attempt.trial_upper || "unknown"
|
||||
if (!failuresByVersion.has(version)) {
|
||||
failuresByVersion.set(version, attempt.error || "No error output captured.")
|
||||
}
|
||||
}
|
||||
|
||||
dependencyFailures.push({
|
||||
packageName: packageResult.package_name,
|
||||
projectPath: packageResult.project_path,
|
||||
dependencyName: dependency.name,
|
||||
originalRequirements: dependency.original_requirements ?? [],
|
||||
finalRequirements: dependency.final_requirements ?? [],
|
||||
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!dependencyFailures.length) {
|
||||
core.info("No failing dependency candidates found.")
|
||||
return
|
||||
}
|
||||
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
})
|
||||
const openIssueTitles = new Set(
|
||||
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
|
||||
)
|
||||
|
||||
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
|
||||
|
||||
for (const failure of dependencyFailures) {
|
||||
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
|
||||
if (openIssueTitles.has(title)) {
|
||||
core.info(`Issue already exists: ${title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const visibleFailures = failure.failedVersions.slice(0, 5)
|
||||
const omittedCount = failure.failedVersions.length - visibleFailures.length
|
||||
const failureDetails = visibleFailures
|
||||
.map(
|
||||
(entry) =>
|
||||
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
|
||||
)
|
||||
.join("\n\n")
|
||||
|
||||
const body = [
|
||||
"Automated dependency range validation found candidate versions that failed checks.",
|
||||
"",
|
||||
`- Package: \`${failure.packageName}\``,
|
||||
`- Project path: \`${failure.projectPath}\``,
|
||||
`- Dependency: \`${failure.dependencyName}\``,
|
||||
`- Original requirements: ${
|
||||
failure.originalRequirements.length
|
||||
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
`- Final requirements after run: ${
|
||||
failure.finalRequirements.length
|
||||
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
"",
|
||||
"### Failed versions and errors",
|
||||
failureDetails,
|
||||
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
|
||||
"",
|
||||
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
|
||||
].join("\n")
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
})
|
||||
openIssueTitles.add(title)
|
||||
core.info(`Created issue: ${title}`)
|
||||
}
|
||||
|
||||
- name: Keep only dev updates when range validation fails
|
||||
if: steps.validate_bounds_test.outcome != 'success' || steps.validate_ranges.outcome != 'success'
|
||||
env:
|
||||
DEV_PATCH: ${{ steps.dev_changes.outputs.patch }}
|
||||
HAS_DEV_CHANGES: ${{ steps.dev_changes.outputs.has_dev_changes }}
|
||||
run: |
|
||||
git restore python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
|
||||
if [ "${HAS_DEV_CHANGES}" = "true" ]; then
|
||||
git apply "${DEV_PATCH}"
|
||||
fi
|
||||
|
||||
- name: Refresh lockfile after dependency range updates
|
||||
if: steps.validate_bounds_test.outcome == 'success' && steps.validate_ranges.outcome == 'success'
|
||||
run: uv lock
|
||||
working-directory: ./python
|
||||
|
||||
- name: Install final dependency set
|
||||
run: uv run poe install
|
||||
working-directory: ./python
|
||||
|
||||
- name: Run final checks
|
||||
run: uv run poe check
|
||||
working-directory: ./python
|
||||
|
||||
- name: Run final typing
|
||||
run: uv run poe typing
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dependency updates
|
||||
id: commit_updates
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-maintenance"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "Python: chore: update dependencies"
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update dependency maintenance tracking issue
|
||||
if: steps.commit_updates.outputs.has_changes == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const branch = "automation/python-dependency-maintenance"
|
||||
const prTitle = "Python: chore: update dependencies"
|
||||
const issueTitle = "Python dependency maintenance PR needed"
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
|
||||
const branchRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${branch}`,
|
||||
})
|
||||
const branchSha = branchRef.data.object.sha
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`
|
||||
const compareUrl = `${context.serverUrl}/${owner}/${repo}/compare/main...${branch}`
|
||||
|
||||
const prBody = [
|
||||
"### Motivation & Context",
|
||||
"",
|
||||
"This automated update keeps Python dependency metadata coherent across the uv workspace. Python dependencies can be declared in multiple `pyproject.toml` files, but the workspace has one shared `python/uv.lock`, so dependency maintenance should update and validate them together instead of through per-manifest Dependabot PRs.",
|
||||
"",
|
||||
"### Description & Review Guide",
|
||||
"",
|
||||
"- **What are the major changes?** Refresh Python dev dependency pins, update package dependency ranges when the bounds tooling succeeds, and refresh `python/uv.lock`.",
|
||||
"- **What is the impact of these changes?** Keeps the Python workspace dependency set current while producing at most one dependency PR for the week. If dependency range validation fails, this PR contains only the dev dependency updates that still pass final validation, and separate issues track failed range candidates.",
|
||||
"- **What do you want reviewers to focus on?** Review the generated dependency metadata changes and any dependency-range updates for package-specific compatibility concerns.",
|
||||
'<!-- NOTE FOR AUTOMATED/AI REVIEWERS: the "What do you want reviewers to focus on?"',
|
||||
" item above is intended for human reviewers only. Automated/AI reviewers should",
|
||||
" ignore it and review the entire change rather than narrowing scope to it. -->",
|
||||
"",
|
||||
"",
|
||||
"### Related Issue",
|
||||
"",
|
||||
"No linked issue; this PR is generated by scheduled Python dependency maintenance.",
|
||||
"",
|
||||
"### Contribution Checklist",
|
||||
"",
|
||||
"- [x] The code builds clean without any errors or warnings",
|
||||
"- [x] All unit tests pass, and I have added new tests where possible",
|
||||
"- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)",
|
||||
"- [ ] This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).",
|
||||
'- [x] **This is not a breaking change.** If it _is_ a breaking change, add the `breaking change` label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.',
|
||||
].join("\n")
|
||||
|
||||
const prBodyFence = "```"
|
||||
const command = [
|
||||
"PR_BODY_FILE=\"$(mktemp)\"",
|
||||
`cat > "$PR_BODY_FILE" <<'EOF'`,
|
||||
prBody,
|
||||
"EOF",
|
||||
"gh pr create --repo microsoft/agent-framework --base main \\",
|
||||
` --head ${owner}:${branch} \\`,
|
||||
` --title "${prTitle}" \\`,
|
||||
" --body-file \"$PR_BODY_FILE\"",
|
||||
].join("\n")
|
||||
|
||||
const issueBody = [
|
||||
"The Python dependency maintenance workflow generated and validated dependency updates, then pushed them to the automation branch.",
|
||||
"",
|
||||
`- Branch: \`${branch}\``,
|
||||
`- Commit: \`${branchSha}\``,
|
||||
`- Compare: ${compareUrl}`,
|
||||
`- Workflow run: ${runUrl}`,
|
||||
"",
|
||||
"GitHub Actions is not permitted to create pull requests in this repository, so a maintainer needs to create the PR manually.",
|
||||
"",
|
||||
"### Create the PR",
|
||||
"",
|
||||
"```bash",
|
||||
command,
|
||||
"```",
|
||||
"",
|
||||
"### Generated PR body",
|
||||
"",
|
||||
prBodyFence,
|
||||
prBody,
|
||||
prBodyFence,
|
||||
].join("\n")
|
||||
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
})
|
||||
const existingIssue = openIssues.find((issue) => !issue.pull_request && issue.title === issueTitle)
|
||||
|
||||
if (existingIssue) {
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: existingIssue.number,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
})
|
||||
core.info(`Updated issue #${existingIssue.number}: ${issueTitle}`)
|
||||
} else {
|
||||
const createdIssue = await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
})
|
||||
core.info(`Created issue #${createdIssue.data.number}: ${issueTitle}`)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates.
|
||||
name: Python - Dependency Range Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
dependency-range-validation:
|
||||
name: Dependency Range Validation
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything)
|
||||
# then we will have to reevaluate.
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Run dependency range validation
|
||||
id: validate_ranges
|
||||
# Keep workflow running so we can still publish diagnostics from this run.
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
working-directory: ./python
|
||||
|
||||
- name: Upload dependency range report
|
||||
# Always publish the report so failures are inspectable even when validation fails.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dependency-range-results
|
||||
path: python/scripts/dependencies/dependency-range-results.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create issues for failed dependency candidates
|
||||
# Always process the report so failed candidates create actionable tracking issues.
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
core.warning(`No dependency range report found at ${reportPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
||||
const dependencyFailures = []
|
||||
|
||||
for (const packageResult of report.packages ?? []) {
|
||||
for (const dependency of packageResult.dependencies ?? []) {
|
||||
const candidateVersions = new Set(dependency.candidate_versions ?? [])
|
||||
const failedAttempts = (dependency.attempts ?? []).filter(
|
||||
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
|
||||
)
|
||||
if (!failedAttempts.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const failuresByVersion = new Map()
|
||||
for (const attempt of failedAttempts) {
|
||||
const version = attempt.trial_upper || "unknown"
|
||||
if (!failuresByVersion.has(version)) {
|
||||
failuresByVersion.set(version, attempt.error || "No error output captured.")
|
||||
}
|
||||
}
|
||||
|
||||
dependencyFailures.push({
|
||||
packageName: packageResult.package_name,
|
||||
projectPath: packageResult.project_path,
|
||||
dependencyName: dependency.name,
|
||||
originalRequirements: dependency.original_requirements ?? [],
|
||||
finalRequirements: dependency.final_requirements ?? [],
|
||||
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!dependencyFailures.length) {
|
||||
core.info("No failing dependency candidates found.")
|
||||
return
|
||||
}
|
||||
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
})
|
||||
const openIssueTitles = new Set(
|
||||
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
|
||||
)
|
||||
|
||||
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
|
||||
|
||||
for (const failure of dependencyFailures) {
|
||||
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
|
||||
if (openIssueTitles.has(title)) {
|
||||
core.info(`Issue already exists: ${title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const visibleFailures = failure.failedVersions.slice(0, 5)
|
||||
const omittedCount = failure.failedVersions.length - visibleFailures.length
|
||||
const failureDetails = visibleFailures
|
||||
.map(
|
||||
(entry) =>
|
||||
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
|
||||
)
|
||||
.join("\n\n")
|
||||
|
||||
const body = [
|
||||
"Automated dependency range validation found candidate versions that failed checks.",
|
||||
"",
|
||||
`- Package: \`${failure.packageName}\``,
|
||||
`- Project path: \`${failure.projectPath}\``,
|
||||
`- Dependency: \`${failure.dependencyName}\``,
|
||||
`- Original requirements: ${
|
||||
failure.originalRequirements.length
|
||||
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
`- Final requirements after run: ${
|
||||
failure.finalRequirements.length
|
||||
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
"",
|
||||
"### Failed versions and errors",
|
||||
failureDetails,
|
||||
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
|
||||
"",
|
||||
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
|
||||
].join("\n")
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
})
|
||||
openIssueTitles.add(title)
|
||||
core.info(`Created issue: ${title}`)
|
||||
}
|
||||
|
||||
- name: Refresh lockfile
|
||||
# Only refresh lockfile after a clean validation to avoid committing known-bad ranges.
|
||||
if: steps.validate_ranges.outcome == 'success'
|
||||
run: uv lock --upgrade
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dependency updates
|
||||
id: commit_updates
|
||||
if: steps.validate_ranges.outcome == 'success'
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-range-updates"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: update dependency ranges"
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update pull request with GitHub CLI
|
||||
# Only open/update PRs for validated updates to keep automation branches trustworthy.
|
||||
if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true'
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-range-updates"
|
||||
PR_TITLE="Python: chore: update dependency ranges"
|
||||
PR_BODY_FILE="$(mktemp)"
|
||||
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
This PR was generated by the dependency range validation workflow.
|
||||
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Updated package dependency bounds
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
EOF
|
||||
|
||||
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
|
||||
if [ -n "${PR_NUMBER}" ]; then
|
||||
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
else
|
||||
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Python - Dev Dependency Upgrade
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
upgrade-dev-dependencies:
|
||||
name: Upgrade Dev Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Upgrade dev dependencies and validate workspace
|
||||
run: uv run poe upgrade-dev-dependencies
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dev dependency updates
|
||||
id: commit_updates
|
||||
run: |
|
||||
BRANCH="automation/python-dev-dependency-updates"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dev dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -F- <<'EOF'
|
||||
Python: chore: upgrade dev dependencies
|
||||
EOF
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update pull request with GitHub CLI
|
||||
if: steps.commit_updates.outputs.has_changes == 'true'
|
||||
run: |
|
||||
BRANCH="automation/python-dev-dependency-updates"
|
||||
PR_TITLE="Python: chore: upgrade dev dependencies"
|
||||
PR_BODY_FILE="$(mktemp)"
|
||||
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
### Motivation and Context
|
||||
|
||||
This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request.
|
||||
|
||||
### Description
|
||||
|
||||
- Ran `uv run poe upgrade-dev-dependencies`
|
||||
- Refreshed dev dependency pins in workspace `pyproject.toml` files
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
- Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test`
|
||||
|
||||
### Contribution Checklist
|
||||
|
||||
- [x] The code builds clean without any errors or warnings
|
||||
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
|
||||
- [x] All unit tests pass, and I have added new tests where possible
|
||||
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
|
||||
EOF
|
||||
|
||||
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
|
||||
if [ -n "${PR_NUMBER}" ]; then
|
||||
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
else
|
||||
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
fi
|
||||
@@ -24,9 +24,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -13,25 +13,13 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Immutable commit SHA to check out"
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY:
|
||||
required: true
|
||||
AZURE_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TENANT_ID:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
FOUNDRY_MODELS_API_KEY:
|
||||
required: false
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -48,7 +36,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -81,7 +69,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -102,7 +90,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -111,9 +99,6 @@ jobs:
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
name: Python Integration Tests - Azure OpenAI
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -127,7 +112,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -138,7 +123,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -156,7 +141,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -178,7 +163,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -192,7 +177,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -232,15 +217,13 @@ jobs:
|
||||
fallback_url: ${{ env.LOCAL_MCP_URL }}
|
||||
- name: Prefer local MCP URL when available
|
||||
run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV"
|
||||
- name: Test with pytest (Anthropic, Hyperlight, Mistral, Ollama, MCP integration)
|
||||
- name: Test with pytest (Anthropic, Hyperlight, Ollama, MCP integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/anthropic/tests
|
||||
packages/hyperlight/tests
|
||||
packages/mistral/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
packages/hosting-mcp/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -248,7 +231,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
@@ -274,12 +257,73 @@ jobs:
|
||||
done
|
||||
kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
|
||||
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
name: Python Integration Tests - Functions
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Set up Azure Functions Integration Test Emulators
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
id: azure-functions-setup
|
||||
- name: Test with pytest (Functions + Durable Task integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/azurefunctions/tests/integration_tests
|
||||
packages/durabletask/tests/integration_tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=480 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry integration tests
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -297,7 +341,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -308,7 +352,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -325,7 +369,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -334,9 +378,6 @@ jobs:
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -347,7 +388,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -358,7 +399,7 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -375,7 +416,7 @@ jobs:
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -402,7 +443,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -427,54 +468,12 @@ jobs:
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Integration Tests - GitHub Copilot
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -487,17 +486,17 @@ jobs:
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
@@ -507,12 +506,12 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
@@ -529,13 +528,13 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -551,20 +550,20 @@ jobs:
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot
|
||||
python-tests-cosmos
|
||||
]
|
||||
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@v8
|
||||
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@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -24,17 +24,13 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
python:
|
||||
- 'python/**'
|
||||
- '!python/AGENTS.md'
|
||||
- '!python/**/AGENTS.md'
|
||||
- '!python/.github/skills/*'
|
||||
- '!python/.github/skills/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -63,7 +59,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
@@ -71,7 +67,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -92,10 +88,13 @@ jobs:
|
||||
- name: Run lab type checking
|
||||
run: cd packages/lab && uv run poe pyright
|
||||
|
||||
- name: Run lab mypy
|
||||
run: cd packages/lab && uv run poe mypy
|
||||
|
||||
# Surface failing tests
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/packages/lab/**.xml
|
||||
summary: true
|
||||
|
||||
@@ -36,13 +36,13 @@ jobs:
|
||||
openaiChanged: ${{ steps.filter.outputs.openai }}
|
||||
azureChanged: ${{ steps.filter.outputs.azure }}
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -67,15 +67,16 @@ jobs:
|
||||
misc:
|
||||
- 'python/packages/anthropic/**'
|
||||
- 'python/packages/hyperlight/**'
|
||||
- 'python/packages/mistral/**'
|
||||
- 'python/packages/ollama/**'
|
||||
- 'python/packages/core/agent_framework/_mcp.py'
|
||||
- 'python/packages/core/tests/core/test_mcp.py'
|
||||
- 'python/packages/hosting-mcp/**'
|
||||
- 'python/scripts/local_mcp_streamable_http_server.py'
|
||||
- '.github/actions/setup-local-mcp-server/**'
|
||||
- '.github/workflows/python-merge-tests.yml'
|
||||
- '.github/workflows/python-integration-tests.yml'
|
||||
functions:
|
||||
- 'python/packages/azurefunctions/**'
|
||||
- 'python/packages/durabletask/**'
|
||||
foundry:
|
||||
- 'python/packages/foundry/**'
|
||||
- 'python/samples/**/providers/foundry/**'
|
||||
@@ -84,8 +85,6 @@ jobs:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
github_copilot:
|
||||
- 'python/packages/github_copilot/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -107,7 +106,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -124,7 +123,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -154,7 +153,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -178,7 +177,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -187,7 +186,7 @@ jobs:
|
||||
title: OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -215,7 +214,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -224,7 +223,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@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -248,7 +247,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -257,7 +256,7 @@ jobs:
|
||||
title: Azure OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
@@ -285,7 +284,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -296,7 +295,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -336,15 +335,13 @@ jobs:
|
||||
fallback_url: ${{ env.LOCAL_MCP_URL }}
|
||||
- name: Prefer local MCP URL when available
|
||||
run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV"
|
||||
- name: Test with pytest (Anthropic, Hyperlight, Mistral, Ollama, MCP integration)
|
||||
- name: Test with pytest (Anthropic, Hyperlight, Ollama, MCP integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/anthropic/tests
|
||||
packages/hyperlight/tests
|
||||
packages/mistral/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
packages/hosting-mcp/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -373,7 +370,7 @@ jobs:
|
||||
kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -382,12 +379,90 @@ jobs:
|
||||
title: Misc integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
name: Python Tests - Functions Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.functionsChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FUNCTIONS_WORKER_RUNTIME: "python"
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
AzureWebJobsStorage: "UseDevelopmentStorage=true"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Set up Azure Functions Integration Test Emulators
|
||||
uses: ./.github/actions/azure-functions-integration-setup
|
||||
id: azure-functions-setup
|
||||
- name: Test with pytest (Functions + Durable Task integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/azurefunctions/tests/integration_tests
|
||||
packages/durabletask/tests/integration_tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=480 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Functions integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
needs: paths-filter
|
||||
@@ -413,7 +488,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -422,7 +497,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@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -440,7 +515,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -449,7 +524,7 @@ jobs:
|
||||
title: Test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
@@ -474,7 +549,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -483,7 +558,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@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -501,7 +576,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -510,7 +585,7 @@ jobs:
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
@@ -545,7 +620,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -568,7 +643,7 @@ jobs:
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
@@ -577,67 +652,12 @@ jobs:
|
||||
title: Cosmos integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Tests - GitHub Copilot Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.githubCopilotChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: GitHub Copilot integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -650,29 +670,29 @@ jobs:
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
@@ -689,13 +709,13 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: integration-test-report
|
||||
path: |
|
||||
@@ -711,22 +731,22 @@ jobs:
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
steps:
|
||||
- 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@v8
|
||||
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@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -33,99 +33,30 @@ jobs:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Resolve the package to build
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
- name: Set environment variables
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Extract package name from tag (format: python-<package>-<version>)
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
PACKAGE=$(echo "$TAG" | sed 's/^python-\([^-]*\)-.*$/\1/')
|
||||
|
||||
TAG="$TAG_NAME"
|
||||
|
||||
# Release tags are either python-<version> for the whole workspace, or
|
||||
# python-<package>-<version> for a single package. Package names may
|
||||
# themselves contain hyphens (hosting-a2a, azure-ai-search), so the
|
||||
# package part cannot be found by splitting on the first hyphen.
|
||||
#
|
||||
# Versions follow the lifecycle patterns in the python-package-management
|
||||
# skill: X.Y.Z, X.Y.ZaYYMMDD, X.Y.ZbYYMMDD, X.Y.ZrcN, each optionally
|
||||
# carrying a .N or .postN re-cut suffix.
|
||||
VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+([ab][0-9]+|rc[0-9]+)?(\.[0-9]+|\.post[0-9]+)?$'
|
||||
|
||||
REST="${TAG#python-}"
|
||||
|
||||
if [[ -z "$REST" ]]; then
|
||||
echo "Error: tag '$TAG' has no version or package component"
|
||||
# Validate package exists
|
||||
if [[ ! -d "packages/$PACKAGE" ]]; then
|
||||
echo "Error: Package '$PACKAGE' not found in packages/ directory"
|
||||
echo "Available packages: $(ls packages/)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$REST" =~ $VERSION_PATTERN ]]; then
|
||||
# python-<version>: build every workspace package plus the root meta package.
|
||||
PACKAGE="all"
|
||||
echo "Resolved tag '$TAG' to the full workspace build"
|
||||
else
|
||||
# python-<package>-<version>: split off the trailing version component and
|
||||
# require it to be a real version, so a malformed tag fails here rather
|
||||
# than being mistaken for another kind of release.
|
||||
CANDIDATE="${REST%-*}"
|
||||
VERSION="${REST##*-}"
|
||||
|
||||
if [[ "$CANDIDATE" == "$REST" || -z "$CANDIDATE" ]]; then
|
||||
echo "Error: tag '$TAG' is neither python-<version> nor python-<package>-<version>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$VERSION" =~ $VERSION_PATTERN ]]; then
|
||||
echo "Error: tag '$TAG' does not end in a supported version"
|
||||
echo "Derived version: '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the package part against the real package directories. Tags use
|
||||
# hyphens even where the directory uses underscores
|
||||
# (python-github-copilot -> github_copilot).
|
||||
PACKAGE=""
|
||||
|
||||
for dir in packages/*/; do
|
||||
name="${dir#packages/}"
|
||||
name="${name%/}"
|
||||
if [[ "$name" == "$CANDIDATE" || "${name//_/-}" == "$CANDIDATE" ]]; then
|
||||
PACKAGE="$name"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$PACKAGE" ]]; then
|
||||
echo "Error: tag '$TAG' does not map to a directory in packages/"
|
||||
echo "Derived package name: '$CANDIDATE'"
|
||||
echo "Available packages: $(ls packages/)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Resolved tag '$TAG' to package '$PACKAGE'"
|
||||
fi
|
||||
|
||||
echo "PACKAGE=$PACKAGE" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE=$PACKAGE" >> $GITHUB_ENV
|
||||
echo "Building package: $PACKAGE"
|
||||
|
||||
- name: Check version
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
echo "Building and uploading Python release: $TAG_NAME"
|
||||
if [[ "$PACKAGE" == "all" ]]; then
|
||||
echo "Build scope: all workspace packages and the root meta package"
|
||||
else
|
||||
echo "Build scope: packages/$PACKAGE"
|
||||
fi
|
||||
echo "Building and uploading Python package version: ${{ github.event.release.tag_name }}"
|
||||
echo "Package directory: packages/${{ env.PACKAGE }}"
|
||||
- name: Build the package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$PACKAGE" == "all" ]]; then
|
||||
uv run poe build
|
||||
else
|
||||
uv run poe --directory "packages/$PACKAGE" build
|
||||
fi
|
||||
run: uv run poe --directory packages/${{ env.PACKAGE }} build
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
python/dist/*
|
||||
|
||||
@@ -8,11 +8,11 @@ on:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_COPILOT_MODEL: auto
|
||||
# GitHub Copilot configuration
|
||||
GITHUB_COPILOT_MODEL: claude-opus-4.6
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
environment: integration
|
||||
env:
|
||||
# Required configuration for get-started samples
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -48,12 +48,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-01-get-started
|
||||
@@ -61,13 +57,12 @@ jobs:
|
||||
|
||||
validate-02-agents:
|
||||
name: Validate 02-agents
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
# Foundry configuration
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# Azure OpenAI configuration
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
@@ -75,19 +70,19 @@ jobs:
|
||||
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
|
||||
# OpenAI configuration
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
# GitHub MCP
|
||||
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
# Observability
|
||||
ENABLE_INSTRUMENTATION: "true"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -113,123 +108,29 @@ jobs:
|
||||
|
||||
- name: Run sample validation
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers harness tools --save-report --report-name 02-agents
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents --exclude providers --save-report --report-name 02-agents
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents
|
||||
path: python/samples/sample_validation/reports/
|
||||
|
||||
validate-02-agents-harness:
|
||||
name: Validate 02-agents/harness
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
# Optional: enables the Foundry memory path in harness samples
|
||||
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
|
||||
FOUNDRY_MEMORY_STORE: ${{ vars.FOUNDRY_MEMORY_STORE || '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
os: ${{ runner.os }}
|
||||
|
||||
- name: Create .env for samples
|
||||
run: |
|
||||
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
|
||||
echo "FOUNDRY_EMBEDDING_MODEL=$FOUNDRY_EMBEDDING_MODEL" >> .env
|
||||
echo "FOUNDRY_MEMORY_STORE=$FOUNDRY_MEMORY_STORE" >> .env
|
||||
|
||||
- name: Run sample validation
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/harness --save-report --report-name 02-agents-harness
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-harness
|
||||
path: python/samples/sample_validation/reports/
|
||||
|
||||
validate-02-agents-tools:
|
||||
name: Validate 02-agents/tools
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
os: ${{ runner.os }}
|
||||
|
||||
- name: Create .env for samples
|
||||
run: |
|
||||
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
|
||||
|
||||
- name: Run sample validation
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/tools --save-report --report-name 02-agents-tools
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-tools
|
||||
path: python/samples/sample_validation/reports/
|
||||
|
||||
validate-02-agents-openai:
|
||||
name: Validate 02-agents/providers/openai
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -250,12 +151,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/openai --save-report --report-name 02-agents-openai
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-openai
|
||||
@@ -263,7 +160,6 @@ jobs:
|
||||
|
||||
validate-02-agents-azure:
|
||||
name: Validate 02-agents/providers/azure
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
@@ -274,7 +170,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -294,12 +190,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/azure --save-report --report-name 02-agents-azure
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-azure
|
||||
@@ -307,7 +199,6 @@ jobs:
|
||||
|
||||
validate-02-agents-anthropic:
|
||||
name: Validate 02-agents/providers/anthropic
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
@@ -317,7 +208,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -336,12 +227,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/anthropic --save-report --report-name 02-agents-anthropic
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-anthropic
|
||||
@@ -349,14 +236,13 @@ jobs:
|
||||
|
||||
validate-02-agents-github-copilot:
|
||||
name: Validate 02-agents/providers/github_copilot
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -370,12 +256,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/github_copilot --save-report --report-name 02-agents-github-copilot
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-github-copilot
|
||||
@@ -392,7 +274,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -406,12 +288,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/amazon --save-report --report-name 02-agents-amazon
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-amazon
|
||||
@@ -428,7 +306,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -442,12 +320,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/ollama --save-report --report-name 02-agents-ollama
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-ollama
|
||||
@@ -455,19 +329,19 @@ jobs:
|
||||
|
||||
validate-02-agents-foundry:
|
||||
name: Validate 02-agents/providers/foundry
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
if: false # Temporarily disabled - provider folder also contains the local Foundry sample
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME || '' }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION || '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -488,12 +362,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/foundry --save-report --report-name 02-agents-foundry
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-foundry
|
||||
@@ -513,7 +383,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -534,12 +404,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/copilotstudio --save-report --report-name 02-agents-copilotstudio
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-copilotstudio
|
||||
@@ -553,7 +419,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -567,12 +433,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 02-agents/providers/custom --save-report --report-name 02-agents-custom
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-02-agents-custom
|
||||
@@ -580,17 +442,16 @@ jobs:
|
||||
|
||||
validate-03-workflows:
|
||||
name: Validate 03-workflows
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -609,79 +470,28 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-03-workflows
|
||||
path: python/samples/sample_validation/reports/
|
||||
|
||||
validate-04-hosting-foundry-hosted-agents:
|
||||
name: Validate 04-hosting (foundry-hosted-agents)
|
||||
validate-04-hosting:
|
||||
name: Validate 04-hosting
|
||||
if: false # Temporarily disabled because of sample complexity
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
# Foundry hosted agent configuration
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ID: ${{ vars.FOUNDRY_PROJECT_ID }}
|
||||
AZURE_CONTAINER_REGISTRY_ENDPOINT: ${{ vars.AZURE_CONTAINER_REGISTRY_ENDPOINT }}
|
||||
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
|
||||
TOOLBOX_ENDPOINT: ${{ vars.TOOLBOX_ENDPOINT }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_NAME }}
|
||||
MEMORY_STORE_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_MEMORY_STORE }}
|
||||
AZURE_SEARCH_ENDPOINT: ${{ vars.AZURE_SEARCH_ENDPOINT }}
|
||||
AZURE_SEARCH_INDEX_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_SEARCH_INDEX_NAME }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
with:
|
||||
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
os: ${{ runner.os }}
|
||||
|
||||
- name: Run sample validation
|
||||
# Maximum parallel workers is set to 1 because all samples use the same port
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 04-hosting/foundry-hosted-agents --save-report --report-name 04-hosting-foundry-hosted-agents --max-parallel-workers 1
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting-foundry-hosted-agents
|
||||
path: python/samples/sample_validation/reports/
|
||||
|
||||
validate-04-hosting-other:
|
||||
name: Validate 04-hosting (other)
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# A2A configuration
|
||||
A2A_AGENT_HOST: http://localhost:5001/
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -693,17 +503,13 @@ jobs:
|
||||
|
||||
- name: Run sample validation
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 04-hosting --exclude foundry-hosted-agents --save-report --report-name 04-hosting-other
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-04-hosting-other
|
||||
name: validation-report-04-hosting
|
||||
path: python/samples/sample_validation/reports/
|
||||
|
||||
validate-05-end-to-end:
|
||||
@@ -712,8 +518,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# Azure OpenAI configuration
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
@@ -728,7 +534,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -742,12 +548,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-05-end-to-end
|
||||
@@ -758,21 +560,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# Azure OpenAI configuration
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# OpenAI configuration
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -792,19 +594,12 @@ jobs:
|
||||
echo "OPENAI_CHAT_COMPLETION_MODEL=$OPENAI_CHAT_COMPLETION_MODEL" >> .env
|
||||
echo "OPENAI_CHAT_MODEL=$OPENAI_CHAT_MODEL" >> .env
|
||||
|
||||
- name: Pre-install AutoGen dependencies for migration samples
|
||||
run: uv pip install "autogen-agentchat" "autogen-ext[openai]"
|
||||
|
||||
- name: Run sample validation
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration --agent-timeout 600
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-autogen-migration
|
||||
@@ -812,25 +607,23 @@ jobs:
|
||||
|
||||
validate-semantic-kernel-migration:
|
||||
name: Validate semantic-kernel-migration
|
||||
if: false # Temporarily disabled - to free up Copilot quota for other jobs
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# Azure OpenAI configuration for AF
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
# Azure OpenAI configuration for SK
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
# OpenAI key
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
# OpenAI configuration for SK
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
# Copilot Studio
|
||||
COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }}
|
||||
COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }}
|
||||
@@ -840,7 +633,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/sample-validation-setup
|
||||
@@ -868,12 +661,8 @@ jobs:
|
||||
run: |
|
||||
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
|
||||
|
||||
- name: Save sample playbooks
|
||||
if: ${{ !cancelled() }}
|
||||
uses: ./.github/actions/sample-validation-save-playbooks
|
||||
|
||||
- name: Upload validation report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-report-semantic-kernel-migration
|
||||
@@ -886,8 +675,6 @@ jobs:
|
||||
needs:
|
||||
- validate-01-get-started
|
||||
- validate-02-agents
|
||||
- validate-02-agents-harness
|
||||
- validate-02-agents-tools
|
||||
- validate-02-agents-openai
|
||||
- validate-02-agents-azure
|
||||
- validate-02-agents-anthropic
|
||||
@@ -898,16 +685,15 @@ jobs:
|
||||
- validate-02-agents-copilotstudio
|
||||
- validate-02-agents-custom
|
||||
- validate-03-workflows
|
||||
- validate-04-hosting-foundry-hosted-agents
|
||||
- validate-04-hosting-other
|
||||
- validate-04-hosting
|
||||
- validate-05-end-to-end
|
||||
- validate-autogen-migration
|
||||
- validate-semantic-kernel-migration
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download all validation reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: validation-report-*
|
||||
path: reports/
|
||||
@@ -915,7 +701,7 @@ jobs:
|
||||
|
||||
- name: Restore validation history
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
@@ -933,13 +719,13 @@ jobs:
|
||||
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save validation history
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
- name: Upload trend report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: validation-trend-report
|
||||
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
@@ -20,46 +19,36 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
path: ./python
|
||||
merge-multiple: true
|
||||
- name: Display structure of downloaded files
|
||||
run: ls
|
||||
- name: Read and validate PR number
|
||||
# Keep the artifact handoff aligned with the workflow run that produced it.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
- name: Read and set PR number
|
||||
# Need to read the PR number from the file saved in the previous workflow
|
||||
# because the workflow_run event does not have access to the PR number
|
||||
# The PR number is needed to post the comment on the PR
|
||||
run: |
|
||||
if [ ! -s pr_number ]; then
|
||||
echo "PR number file 'pr_number' is missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARTIFACT_PR_NUMBER=$(cat pr_number)
|
||||
if ! [[ "$ARTIFACT_PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::PR number file contains invalid content"
|
||||
PR_NUMBER=$(head -1 pr_number | tr -dc '0-9')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "PR number file 'pr_number' does not contain a valid PR number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_HEAD_SHA=$(gh pr view "$ARTIFACT_PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
|
||||
if [ "$PR_HEAD_SHA" != "$RUN_HEAD_SHA" ]; then
|
||||
echo "::error::PR head SHA does not match the triggering workflow run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR_NUMBER=$ARTIFACT_PR_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||
- name: Pytest coverage comment
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@dd5b80bde6d16941f336518e92929e89069d8451 # v1.7.2
|
||||
uses: MishaKav/pytest-coverage-comment@v1.6.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
pytest-xml-coverage-path: python/python-coverage.xml
|
||||
title: "Python Test Coverage Report"
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
paths:
|
||||
- "python/packages/**"
|
||||
- "python/tests/unit/**"
|
||||
- "python/scripts/workspace_poe_tasks.py"
|
||||
- ".github/scripts/python_check_coverage.py"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -25,7 +22,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
# in the coverage report workflow does not have access to it
|
||||
- name: Save PR number
|
||||
@@ -40,12 +37,12 @@ jobs:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run aggregate tests with coverage report
|
||||
- name: Run all tests with coverage report
|
||||
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/scripts/python_check_coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: |
|
||||
python/python-coverage.xml
|
||||
|
||||
@@ -5,10 +5,6 @@ on:
|
||||
branches: ["main", "feature*"]
|
||||
paths:
|
||||
- "python/**"
|
||||
- "!python/AGENTS.md"
|
||||
- "!python/**/AGENTS.md"
|
||||
- "!python/.github/skills/*"
|
||||
- "!python/.github/skills/**"
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -31,14 +27,14 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -50,7 +46,7 @@ jobs:
|
||||
# Surface failing tests
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
|
||||
@@ -26,31 +26,14 @@ jobs:
|
||||
ping_stale:
|
||||
name: "Ping stale issues and PRs"
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
@@ -60,7 +43,7 @@ jobs:
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
|
||||
@@ -206,11 +206,6 @@ temp*/
|
||||
.temp/
|
||||
|
||||
# AI
|
||||
**/.checkpoints/
|
||||
# Local AgentServer file store + crash-recovery HOME roots used by hosted samples
|
||||
**/.agentserver-state/
|
||||
**/.agentserver-state-*/
|
||||
**/.home-*/
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
@@ -218,7 +213,6 @@ WARP.md
|
||||
**/memory-bank/
|
||||
**/projectBrief.md
|
||||
**/tmpclaude*
|
||||
.kiro/
|
||||
# Dependency-bound validation reports
|
||||
python/scripts/dependency-*-results.json
|
||||
python/scripts/dependencies/dependency-*-results.json
|
||||
@@ -254,4 +248,3 @@ dotnet/filtered-*.slnx
|
||||
.omx/
|
||||
|
||||
**/issues/
|
||||
.test_*
|
||||
|
||||
@@ -127,28 +127,9 @@ We use and recommend the following workflow:
|
||||
7. Create a PR against the repository's **main** branch.
|
||||
- State in the description what issue or improvement your change is addressing.
|
||||
- Verify that all the Continuous Integration checks are passing.
|
||||
8. Address feedback from the code maintainers. Reply to every review comment with
|
||||
the outcome and resolve each completed review conversation yourself before
|
||||
requesting another review.
|
||||
8. Wait for feedback or approval of your changes from the code maintainers.
|
||||
9. When area owners have signed off, and all checks are green, your PR will be merged.
|
||||
|
||||
### Resolving PR Review Comments
|
||||
|
||||
PR authors are responsible for closing out all review conversations on their pull
|
||||
requests, including conversations opened by reviewers. Do not wait for the reviewer
|
||||
or a maintainer to resolve completed conversations for you.
|
||||
|
||||
For every review comment:
|
||||
|
||||
- If the feedback was addressed, reply with a brief explanation and, preferably,
|
||||
the commit containing the change.
|
||||
- If the feedback was not addressed, reply with the reason why.
|
||||
|
||||
After replying and completing any necessary discussion, **resolve the conversation
|
||||
yourself**. Leave a conversation open only while it has an unanswered question or
|
||||
active discussion. Reviewers may reopen a conversation if further changes or
|
||||
discussion are needed.
|
||||
|
||||
### Development Setup
|
||||
|
||||
Each language has its own dev setup guide, coding standards, and build scripts:
|
||||
|
||||
@@ -6,15 +6,12 @@
|
||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||
[](https://pypi.org/project/agent-framework/)
|
||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||
[](https://github.com/microsoft/agent-framework)
|
||||
[](https://github.com/microsoft/agent-framework/stargazers)
|
||||
|
||||
|
||||
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
|
||||
|
||||
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python, .NET and Go, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
|
||||
|
||||
> [!NOTE]
|
||||
> For the Go SDK, including its documentation, samples, contribution guidance, and issue tracker, visit [microsoft/agent-framework-go](https://github.com/microsoft/agent-framework-go/).
|
||||
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
|
||||
@@ -42,7 +39,6 @@ Explore new MAF capabilities and real implementation patterns on the [official b
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Go Support**: For the Go SDK, including its documentation, samples, contribution guidance, and issue tracker, visit [microsoft/agent-framework-go](https://github.com/microsoft/agent-framework-go/).
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
@@ -165,19 +161,19 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
|
||||
### Python
|
||||
|
||||
- [Getting Started](./python/samples/01-get-started): progressive tutorial from hello-world to workflows
|
||||
- [Getting Started](./python/samples/01-get-started): progressive tutorial from hello-world to hosting
|
||||
- [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.)
|
||||
- [Workflows](./python/samples/03-workflows): workflow creation and integration with agents
|
||||
- [Hosting](./python/samples/04-hosting): A2A, self-hosted protocol helpers, and Foundry hosted agents. Durable Task and Azure Functions samples are in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/python/samples).
|
||||
- [Hosting](./python/samples/04-hosting): A2A, Azure Functions, Durable Task hosting
|
||||
- [End-to-End](./python/samples/05-end-to-end): full applications, evaluation, and demos
|
||||
|
||||
### .NET
|
||||
|
||||
- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to workflows
|
||||
- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to hosting
|
||||
- [Agent Concepts](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage
|
||||
- [Agent Providers](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers
|
||||
- [Workflows](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration
|
||||
- [Hosting](./dotnet/samples/04-hosting): A2A and Foundry hosted agents. Durable agent and workflow samples are in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension/tree/main/dotnet/samples).
|
||||
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
||||
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
||||
|
||||
## Community & Feedback
|
||||
@@ -203,7 +199,6 @@ For environment variable configuration specific to each sample, refer to the REA
|
||||
## Contributor Resources
|
||||
|
||||
- [Contributing Guide](./CONTRIBUTING.md)
|
||||
- [Code of Conduct](./CODE_OF_CONDUCT.md)
|
||||
- [Python Development Guide](./python/DEV_SETUP.md)
|
||||
- [Design Documents](./docs/design)
|
||||
- [Architectural Decision Records](./docs/decisions)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# Support
|
||||
|
||||
## How to file issues and get help
|
||||
|
||||
This project uses GitHub Issues to track bugs and feature requests. Please search the existing
|
||||
issues before filing new issues to avoid duplicates. For new issues, file your bug or
|
||||
feature request as a new Issue.
|
||||
|
||||
For help and questions about using this project, please create a GitHub issue.
|
||||
|
||||
AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance
|
||||
|
||||
For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels.
|
||||
|
||||
## Microsoft Support Policy
|
||||
|
||||
Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
|
||||
# Support
|
||||
|
||||
## How to file issues and get help
|
||||
|
||||
This project uses GitHub Issues to track bugs and feature requests. Please search the existing
|
||||
issues before filing new issues to avoid duplicates. For new issues, file your bug or
|
||||
feature request as a new Issue.
|
||||
|
||||
For help and questions about using this project, please create a GitHub issue.
|
||||
|
||||
AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance
|
||||
|
||||
For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels.
|
||||
|
||||
## Microsoft Support Policy
|
||||
|
||||
Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**What is Microsoft Agent Framework?**
|
||||
|
||||
Microsoft Agent Framework is a comprehensive multi-language (C#/.NET and Python) framework for building, orchestrating, and deploying AI agents and multi-agent workflows. The system takes user instructions and conversation inputs and produces intelligent responses through AI agents that can integrate with various LLM providers (OpenAI, Azure OpenAI, Microsoft Foundry). It provides both simple chat agents and complex multi-agent workflows with graph-based orchestration.
|
||||
Microsoft Agent Framework is a comprehensive multi-language (C#/.NET and Python) framework for building, orchestrating, and deploying AI agents and multi-agent workflows. The system takes user instructions and conversation inputs and produces intelligent responses through AI agents that can integrate with various LLM providers (OpenAI, Azure OpenAI, Azure AI Foundry). It provides both simple chat agents and complex multi-agent workflows with graph-based orchestration.
|
||||
|
||||
**What can Microsoft Agent Framework do?**
|
||||
|
||||
@@ -12,7 +12,7 @@ The framework offers:
|
||||
- **Multi-Agent Orchestration**: Group chat, sequential, concurrent, and handoff patterns
|
||||
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, time-travel, and Human-in-the-loop
|
||||
- **Extensibility Framework**: Extend with native functions, A2A, Model Context Protocol (MCP)
|
||||
- **LLM Integration**: Support for OpenAI, Azure OpenAI, Microsoft Foundry, and other providers
|
||||
- **LLM Integration**: Support for OpenAI, Azure OpenAI, Azure AI Foundry, and other providers
|
||||
- **Runtime Support**: Both in-process and distributed agent execution
|
||||
|
||||
**What is/are Microsoft Agent Framework's intended use(s)?**
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
# - Knowledge Agent: Performs generic web searches.
|
||||
# - Coder Agent: Able to write and execute code.
|
||||
# - Weather Agent: Provides weather information.
|
||||
#
|
||||
# Example input:
|
||||
# Find the current temperatures in Seattle and San Francisco, calculate the difference in Celsius and Fahrenheit, and recommend what clothing to pack for each city.
|
||||
#
|
||||
kind: Workflow
|
||||
maxTurns: 500
|
||||
@@ -267,14 +264,14 @@ trigger:
|
||||
output:
|
||||
messages: Local.Plan
|
||||
input:
|
||||
arguments:
|
||||
team: =Local.TeamDescription
|
||||
messages: |-
|
||||
=UserMessage(
|
||||
"Please briefly explain what went wrong on this last run (the root cause of the failure),
|
||||
and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes.
|
||||
As before, the new plan should be concise, be expressed in bullet-point form, and only involve the team members already described
|
||||
(do not involve any other outside people since we cannot contact anyone else).")
|
||||
As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition
|
||||
(do not involve any other outside people since we cannot contact anyone else):
|
||||
|
||||
{Local.TeamDescription}")
|
||||
|
||||
- kind: SetTextVariable
|
||||
id: setVariable_jW7tmM
|
||||
|
||||
@@ -11,7 +11,7 @@ trigger:
|
||||
kind: OnConversationStart
|
||||
id: workflow_demo
|
||||
actions:
|
||||
|
||||
|
||||
- kind: InvokeAzureAgent
|
||||
id: question_student
|
||||
conversationId: =System.ConversationId
|
||||
|
||||
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,55 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
|
||||
<path d="M163.006 36.0262C155.129 31.4785 145.424 31.4759 137.533 36.0284L104.002 55.3877C107.838 53.2763 112.07 52.2274 116.296 52.2248C120.633 52.2231 124.976 53.3247 128.871 55.5411L174.091 81.6479C175.11 82.2362 175.738 83.3236 175.738 84.5004V151.263C175.738 152.716 177.313 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.7299C209.704 68.6342 204.855 60.227 196.967 55.6697L190.983 52.2141C190.65 52.0008 190.313 51.7921 189.969 51.5933L163.006 36.0262Z" fill="url(#paint0_linear_481_4810)"/>
|
||||
<path d="M163.006 36.0262C155.129 31.4785 145.424 31.4759 137.533 36.0284L104.002 55.3877C107.838 53.2763 112.07 52.2274 116.296 52.2248C120.633 52.2231 124.976 53.3247 128.871 55.5411L174.091 81.6479C175.11 82.2362 175.738 83.3236 175.738 84.5004V151.263C175.738 152.716 177.313 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.7299C209.704 68.6342 204.855 60.227 196.967 55.6697L190.983 52.2141C190.65 52.0008 190.313 51.7921 189.969 51.5933L163.006 36.0262Z" fill="url(#paint1_linear_481_4810)"/>
|
||||
<path d="M103.548 55.6397L103.557 55.6451L104.002 55.3877C103.851 55.471 103.698 55.5531 103.548 55.6397Z" fill="url(#paint2_linear_481_4810)"/>
|
||||
<path d="M103.548 55.6397L103.557 55.6451L104.002 55.3877C103.851 55.471 103.698 55.5531 103.548 55.6397Z" fill="url(#paint3_linear_481_4810)"/>
|
||||
<path d="M116.308 52.2209C111.903 52.2271 107.507 53.3498 103.561 55.6366C95.6702 60.1891 90.8239 68.6019 90.8231 77.6986L90.8223 167.846C90.8222 169.786 92.871 171.041 94.599 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9894C124.778 58.6476 129.49 55.9209 133.25 58.0698L128.879 55.5453C124.976 53.3242 120.645 52.2192 116.308 52.2209Z" fill="url(#paint4_linear_481_4810)"/>
|
||||
<path d="M95.3068 221.682C103.184 226.23 112.889 226.232 120.78 221.68L154.311 202.32C150.475 204.432 146.243 205.481 142.018 205.483C137.68 205.485 133.337 204.383 129.442 202.167L84.2226 176.06C83.2035 175.472 82.5757 174.384 82.5757 173.208L82.5757 106.445C82.5757 104.992 81 104.087 79.7451 104.813L61.3465 115.428C53.4682 119.976 48.6091 128.382 48.6089 137.487L48.6089 179.978C48.6089 189.074 53.4585 197.481 61.3465 202.038L67.3303 205.494C67.6629 205.707 68.0002 205.916 68.3446 206.115L95.3068 221.682Z" fill="url(#paint5_linear_481_4810)"/>
|
||||
<path d="M95.3068 221.682C103.184 226.23 112.889 226.232 120.78 221.68L154.311 202.32C150.475 204.432 146.243 205.481 142.018 205.483C137.68 205.485 133.337 204.383 129.442 202.167L84.2226 176.06C83.2035 175.472 82.5757 174.384 82.5757 173.208L82.5757 106.445C82.5757 104.992 81 104.087 79.7451 104.813L61.3465 115.428C53.4682 119.976 48.6091 128.382 48.6089 137.487L48.6089 179.978C48.6089 189.074 53.4585 197.481 61.3465 202.038L67.3303 205.494C67.6629 205.707 68.0002 205.916 68.3446 206.115L95.3068 221.682Z" fill="url(#paint6_linear_481_4810)"/>
|
||||
<path d="M154.765 202.068L154.756 202.063L154.311 202.32C154.463 202.237 154.615 202.155 154.765 202.068Z" fill="url(#paint7_linear_481_4810)"/>
|
||||
<path d="M154.765 202.068L154.756 202.063L154.311 202.32C154.463 202.237 154.615 202.155 154.765 202.068Z" fill="url(#paint8_linear_481_4810)"/>
|
||||
<path d="M142.003 205.487C146.408 205.481 150.805 204.358 154.751 202.071C162.641 197.519 167.488 189.106 167.488 180.009L167.489 89.8618C167.489 87.9222 165.44 86.667 163.712 87.5479L154.788 92.0972C141.739 98.7494 133.523 112.159 133.523 126.806L133.523 194.719C133.533 199.06 128.821 201.787 125.061 199.638L129.432 202.163C133.336 204.384 137.666 205.489 142.003 205.487Z" fill="url(#paint9_linear_481_4810)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_481_4810" x1="207.413" y1="61.882" x2="148.999" y2="163.067" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_481_4810" x1="188.235" y1="204.189" x2="264.21" y2="152.592" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_481_4810" x1="207.413" y1="61.882" x2="148.999" y2="163.067" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_481_4810" x1="188.235" y1="204.189" x2="264.21" y2="152.592" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_481_4810" x1="93.1761" y1="128.826" x2="66.2399" y2="104.746" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.25" stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#2C08AC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_481_4810" x1="50.9004" y1="195.826" x2="109.315" y2="94.6412" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_481_4810" x1="70.0787" y1="53.5188" x2="-5.89657" y2="105.116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_481_4810" x1="50.9004" y1="195.826" x2="109.315" y2="94.6412" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9189F7"/>
|
||||
<stop offset="1" stop-color="#4135E9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_481_4810" x1="70.0787" y1="53.5188" x2="-5.89657" y2="105.116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#7274FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_481_4810" x1="165.135" y1="128.882" x2="192.072" y2="152.962" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.25" stop-color="#4F42FD"/>
|
||||
<stop offset="1" stop-color="#2C08AC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
|
||||
<path d="M167.489 89.8618C167.489 87.9224 165.441 86.667 163.713 87.5473L154.788 92.0971C141.739 98.7493 133.523 112.159 133.523 126.806V194.718C133.533 199.053 128.837 201.777 125.08 199.647L84.2227 176.06C83.2036 175.472 82.5762 174.384 82.5762 173.207V106.445C82.5759 104.992 80.9999 104.087 79.7451 104.813L61.3467 115.428C53.4685 119.976 48.6096 128.382 48.6094 137.487V179.978C48.6094 189.074 53.4588 197.481 61.3467 202.039L67.3301 205.494C67.6627 205.707 68.0004 205.916 68.3447 206.115L95.3066 221.682C103.184 226.23 112.889 226.232 120.779 221.679L154.312 202.32C154.271 202.342 154.23 202.362 154.189 202.384C154.283 202.333 154.375 202.281 154.468 202.229L154.312 202.32C154.463 202.237 154.615 202.154 154.765 202.068L154.762 202.065C162.646 197.511 167.487 189.102 167.488 180.009L167.489 89.8618Z" fill="black"/>
|
||||
<path d="M163.007 36.0259C155.13 31.4781 145.424 31.4763 137.533 36.0288L104.002 55.3882C104.029 55.3732 104.057 55.359 104.084 55.3442C104.02 55.3794 103.956 55.4159 103.893 55.4516L104.002 55.3882C103.851 55.4714 103.699 55.5536 103.549 55.6401L103.552 55.6411C95.6664 60.1948 90.8241 68.6053 90.8232 77.6987L90.8223 167.846C90.8223 169.786 92.8707 171.04 94.5986 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9897C124.778 58.6479 129.49 55.9211 133.25 58.0698L174.091 81.6479C175.11 82.2363 175.737 83.3238 175.737 84.5005V151.263C175.738 152.716 177.314 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.73C209.704 68.6343 204.855 60.2267 196.967 55.6694L190.982 52.2143C190.65 52.0011 190.313 51.792 189.969 51.5932L163.007 36.0259Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="black"/>
|
||||
<path d="M167.489 89.8618C167.489 87.9224 165.441 86.667 163.713 87.5473L154.788 92.0971C141.739 98.7493 133.523 112.159 133.523 126.806V194.718C133.533 199.053 128.837 201.777 125.08 199.647L84.2227 176.06C83.2036 175.472 82.5762 174.384 82.5762 173.207V106.445C82.5759 104.992 80.9999 104.087 79.7451 104.813L61.3467 115.428C53.4685 119.976 48.6096 128.382 48.6094 137.487V179.978C48.6094 189.074 53.4588 197.481 61.3467 202.039L67.3301 205.494C67.6627 205.707 68.0004 205.916 68.3447 206.115L95.3066 221.682C103.184 226.23 112.889 226.232 120.779 221.679L154.312 202.32C154.271 202.342 154.23 202.362 154.189 202.384C154.283 202.333 154.375 202.281 154.468 202.229L154.312 202.32C154.463 202.237 154.615 202.154 154.765 202.068L154.762 202.065C162.646 197.511 167.487 189.102 167.488 180.009L167.489 89.8618Z" fill="white"/>
|
||||
<path d="M163.007 36.0259C155.13 31.4781 145.424 31.4763 137.533 36.0288L104.002 55.3882C104.029 55.3732 104.057 55.359 104.084 55.3442C104.02 55.3794 103.956 55.4159 103.893 55.4516L104.002 55.3882C103.851 55.4714 103.699 55.5536 103.549 55.6401L103.552 55.6411C95.6664 60.1948 90.8241 68.6053 90.8232 77.6987L90.8223 167.846C90.8223 169.786 92.8707 171.04 94.5986 170.16L103.523 165.611C116.573 158.959 124.788 145.549 124.788 130.902V62.9897C124.778 58.6479 129.49 55.9211 133.25 58.0698L174.091 81.6479C175.11 82.2363 175.737 83.3238 175.737 84.5005V151.263C175.738 152.716 177.314 153.621 178.568 152.895L196.967 142.28C204.845 137.732 209.704 129.326 209.704 120.221V77.73C209.704 68.6343 204.855 60.2267 196.967 55.6694L190.982 52.2143C190.65 52.0011 190.313 51.792 189.969 51.5932L163.007 36.0259Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="white"/>
|
||||
<path d="M48.6094 179.978V137.487C48.6096 128.382 53.4685 119.976 61.3467 115.428L79.7451 104.813C80.9999 104.087 82.5759 104.992 82.5762 106.445V173.207C82.5762 174.384 83.2036 175.472 84.2227 176.06L125.08 199.647C128.837 201.777 133.533 199.053 133.523 194.718V126.806C133.523 112.388 141.484 99.1684 154.18 92.4135L154.788 92.0971L163.713 87.5473C165.441 86.667 167.489 87.9224 167.489 89.8618L167.488 180.009L167.474 180.86C167.181 189.624 162.399 197.653 154.762 202.065L154.765 202.068C154.615 202.154 154.463 202.237 154.312 202.32L154.468 202.229C154.375 202.281 154.283 202.333 154.189 202.384C154.23 202.362 154.271 202.342 154.312 202.32L120.779 221.679L120.034 222.092C112.534 226.093 103.539 226.092 96.0508 222.095L95.3066 221.682L68.3447 206.115C68.0004 205.916 67.6627 205.707 67.3301 205.494L61.3467 202.039C53.7053 197.624 48.9149 189.595 48.623 180.829L48.6094 179.978ZM175.737 84.5005C175.737 83.3974 175.186 82.3728 174.277 81.7641L174.091 81.6479L133.25 58.0698C129.49 55.9211 124.778 58.6479 124.788 62.9897V130.902L124.782 131.587C124.53 145.966 116.369 159.063 103.523 165.611L94.5986 170.16L94.4355 170.236C92.799 170.938 90.9459 169.803 90.8281 168.026L90.8223 167.846L90.8232 77.6987C90.8241 68.6053 95.6664 60.1948 103.552 55.6411L103.549 55.6401C103.699 55.5536 103.851 55.4714 104.002 55.3882L103.893 55.4516C103.956 55.4159 104.02 55.3794 104.084 55.3442C104.057 55.359 104.029 55.3732 104.002 55.3882L137.533 36.0288C145.424 31.4763 155.13 31.4781 163.007 36.0259L189.969 51.5932C190.313 51.792 190.65 52.0011 190.982 52.2143L196.967 55.6694C204.855 60.2267 209.704 68.6343 209.704 77.73V120.221L209.689 121.073C209.397 129.848 204.599 137.874 196.967 142.28L178.568 152.895C177.314 153.621 175.738 152.716 175.737 151.263V84.5005ZM176.814 149.866L176.819 149.864L176.832 149.856C176.826 149.859 176.82 149.862 176.814 149.866ZM137.023 194.71L137.019 195.038C136.802 201.878 129.341 206.087 123.354 202.692L123.33 202.678L82.4727 179.091C80.3698 177.877 79.0762 175.634 79.0762 173.207V109.239L63.0957 118.458C56.5124 122.259 52.3745 129.183 52.1221 136.752L52.1094 137.487V179.978C52.1094 187.823 56.2909 195.075 63.0957 199.007H63.0967L69.0801 202.462L69.1504 202.503L69.2188 202.547C69.5212 202.741 69.8111 202.92 70.0947 203.083L97.0566 218.651L97.6982 219.007C104.372 222.569 112.434 222.453 119.029 218.648L152.514 199.316L152.512 199.312C152.581 199.274 152.65 199.236 152.752 199.178L152.753 199.181C152.775 199.169 152.796 199.158 152.815 199.147L153.011 199.035C159.81 195.107 163.987 187.854 163.988 180.009V91.3344L156.378 95.2153C144.501 101.27 137.023 113.475 137.023 126.806V194.71ZM94.3223 166.372L101.934 162.493C113.811 156.438 121.288 144.233 121.288 130.902V62.9897C121.278 55.9521 128.901 51.5532 134.986 55.0307L135 55.0385L175.841 78.6167L176.036 78.7339C178.023 79.9714 179.237 82.15 179.237 84.5005V148.468L195.217 139.249C202.013 135.326 206.204 128.075 206.204 120.221V77.73C206.204 69.8848 202.022 62.6318 195.217 58.6997L189.232 55.2456L189.162 55.2046L189.094 55.1606C188.788 54.9649 188.5 54.787 188.219 54.6245L161.257 39.0571C154.463 35.135 146.091 35.1311 139.282 39.0591L139.283 39.06L105.765 58.4106L105.767 58.4135C105.713 58.443 105.723 58.4383 105.602 58.5063L105.6 58.5034C105.535 58.5391 105.481 58.5691 105.433 58.5962L105.302 58.6723C98.5016 62.5995 94.324 69.8532 94.3232 77.6987L94.3223 166.372Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" transform="matrix(-1 0 0 1 256 0)" fill="black"/>
|
||||
<path d="M48.6094 179.978V137.487C48.6096 128.382 53.4685 119.976 61.3467 115.428L79.7451 104.813C80.9999 104.087 82.5759 104.992 82.5762 106.445V173.207C82.5762 174.384 83.2036 175.472 84.2227 176.06L125.08 199.647C128.837 201.777 133.533 199.053 133.523 194.718V126.806C133.523 112.388 141.484 99.1684 154.18 92.4135L154.788 92.0971L163.713 87.5473C165.441 86.667 167.489 87.9224 167.489 89.8618L167.488 180.009L167.474 180.86C167.181 189.624 162.399 197.653 154.762 202.065L154.765 202.068C154.615 202.154 154.463 202.237 154.312 202.32L154.468 202.229C154.375 202.281 154.283 202.333 154.189 202.384C154.23 202.362 154.271 202.342 154.312 202.32L120.779 221.679L120.034 222.092C112.534 226.093 103.539 226.092 96.0508 222.095L95.3066 221.682L68.3447 206.115C68.0004 205.916 67.6627 205.707 67.3301 205.494L61.3467 202.039C53.7053 197.624 48.9149 189.595 48.623 180.829L48.6094 179.978ZM175.737 84.5005C175.737 83.3974 175.186 82.3728 174.277 81.7641L174.091 81.6479L133.25 58.0698C129.49 55.9211 124.778 58.6479 124.788 62.9897V130.902L124.782 131.587C124.53 145.966 116.369 159.063 103.523 165.611L94.5986 170.16L94.4355 170.236C92.799 170.938 90.9459 169.803 90.8281 168.026L90.8223 167.846L90.8232 77.6987C90.8241 68.6053 95.6664 60.1948 103.552 55.6411L103.549 55.6401C103.699 55.5536 103.851 55.4714 104.002 55.3882L103.893 55.4516C103.956 55.4159 104.02 55.3794 104.084 55.3442C104.057 55.359 104.029 55.3732 104.002 55.3882L137.533 36.0288C145.424 31.4763 155.13 31.4781 163.007 36.0259L189.969 51.5932C190.313 51.792 190.65 52.0011 190.982 52.2143L196.967 55.6694C204.855 60.2267 209.704 68.6343 209.704 77.73V120.221L209.689 121.073C209.397 129.848 204.599 137.874 196.967 142.28L178.568 152.895C177.314 153.621 175.738 152.716 175.737 151.263V84.5005ZM176.814 149.866L176.819 149.864L176.832 149.856C176.826 149.859 176.82 149.862 176.814 149.866ZM137.023 194.71L137.019 195.038C136.802 201.878 129.341 206.087 123.354 202.692L123.33 202.678L82.4727 179.091C80.3698 177.877 79.0762 175.634 79.0762 173.207V109.239L63.0957 118.458C56.5124 122.259 52.3745 129.183 52.1221 136.752L52.1094 137.487V179.978C52.1094 187.823 56.2909 195.075 63.0957 199.007H63.0967L69.0801 202.462L69.1504 202.503L69.2188 202.547C69.5212 202.741 69.8111 202.92 70.0947 203.083L97.0566 218.651L97.6982 219.007C104.372 222.569 112.434 222.453 119.029 218.648L152.514 199.316L152.512 199.312C152.581 199.274 152.65 199.236 152.752 199.178L152.753 199.181C152.775 199.169 152.796 199.158 152.815 199.147L153.011 199.035C159.81 195.107 163.987 187.854 163.988 180.009V91.3344L156.378 95.2153C144.501 101.27 137.023 113.475 137.023 126.806V194.71ZM94.3223 166.372L101.934 162.493C113.811 156.438 121.288 144.233 121.288 130.902V62.9897C121.278 55.9521 128.901 51.5532 134.986 55.0307L135 55.0385L175.841 78.6167L176.036 78.7339C178.023 79.9714 179.237 82.15 179.237 84.5005V148.468L195.217 139.249C202.013 135.326 206.204 128.075 206.204 120.221V77.73C206.204 69.8848 202.022 62.6318 195.217 58.6997L189.232 55.2456L189.162 55.2046L189.094 55.1606C188.788 54.9649 188.5 54.787 188.219 54.6245L161.257 39.0571C154.463 35.135 146.091 35.1311 139.282 39.0591L139.283 39.06L105.765 58.4106L105.767 58.4135C105.713 58.443 105.723 58.4383 105.602 58.5063L105.6 58.5034C105.535 58.5391 105.481 58.5691 105.433 58.5962L105.302 58.6723C98.5016 62.5995 94.324 69.8532 94.3232 77.6987L94.3223 166.372Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 136 KiB |
@@ -2,7 +2,7 @@
|
||||
# These are optional elements. Feel free to remove any of them.
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2025-07-10
|
||||
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
|
||||
consulted:
|
||||
informed:
|
||||
@@ -139,7 +139,7 @@ Therefore something like `AgentResponse.Text` which also aggregates all `TextCon
|
||||
|
||||
#### Option 1.2 Presence of Secondary Content is determined by a runtime parameter
|
||||
|
||||
We can allow callers to choose whether to include secondary content in the list of response messages.
|
||||
We can allow callers to choose whether to include secondary content in the list of reponse messages.
|
||||
Open Question: Do we allow secondary content to use `TextContent` types?
|
||||
|
||||
```csharp
|
||||
|
||||
@@ -113,7 +113,7 @@ Implement a hybrid strategy where common tools use generic `AITool`-derived abst
|
||||
|
||||
### AI Agent Tool Types Availability
|
||||
|
||||
Tool Type | Microsoft Foundry Agent Service | OpenAI Assistant API | OpenAI ChatCompletion API | OpenAI Responses API | Amazon Bedrock Agents | Google | Anthropic | Description
|
||||
Tool Type | Azure AI Foundry Agent Service | OpenAI Assistant API | OpenAI ChatCompletion API | OpenAI Responses API | Amazon Bedrock Agents | Google | Anthropic | Description
|
||||
-- | -- | -- | -- | -- | -- | -- | -- | --
|
||||
Function Calling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Enables custom, stateless functions to define specific agent behaviors.
|
||||
Code Interpreter | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | Allows agents to execute code for tasks like data analysis or problem-solving.
|
||||
@@ -132,7 +132,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Function Calling
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/function-calling?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/function-calling?pivots=rest</a>
|
||||
|
||||
Message Request:
|
||||
@@ -401,7 +401,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Code Interpreter
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<p>Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/code-interpreter-samples?pivots=rest-api">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/code-interpreter-samples?pivots=rest-api</a></p>
|
||||
|
||||
<p>.NET Support: ✅</p>
|
||||
@@ -709,7 +709,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Search and Retrieval
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/file-search-upload-files?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/file-search-upload-files?pivots=rest</a>
|
||||
|
||||
File Search Request:
|
||||
@@ -1083,7 +1083,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Web Search
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-code-samples?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-code-samples?pivots=rest</a>
|
||||
|
||||
Bing Search Message Request:
|
||||
@@ -1630,7 +1630,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### OpenAPI Spec Tool
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/openapi-spec-samples?pivots=rest-api">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/openapi-spec-samples?pivots=rest-api</a><br>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/rest/api/aifoundry/aiagents/run-steps/get-run-step?view=rest-aifoundry-aiagents-v1&tabs=HTTP#runstepopenapitoolcall">https://learn.microsoft.com/en-us/rest/api/aifoundry/aiagents/run-steps/get-run-step?view=rest-aifoundry-aiagents-v1&tabs=HTTP#runstepopenapitoolcall</a>
|
||||
|
||||
@@ -1712,7 +1712,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Stateful Functions
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/azure-functions-samples?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/azure-functions-samples?pivots=rest</a>
|
||||
|
||||
Message Request:
|
||||
@@ -1832,7 +1832,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Microsoft Fabric
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/fabric?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/fabric?pivots=rest</a>
|
||||
|
||||
Message Request:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# These are optional elements. Feel free to remove any of them.
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2025-09-12
|
||||
date: 2025-09-12 {YYYY-MM-DD when the decision was last updated}
|
||||
deciders: sergeymenshykh, markwallace-microsoft, rogerbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub, peterychang
|
||||
consulted:
|
||||
informed:
|
||||
@@ -25,7 +25,7 @@ See various features that would need to be supported via this type of mechanism,
|
||||
- Also see [the openai human-in-the-loop guide](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/#approval-requests).
|
||||
- Also see [the openai MCP guide](https://openai.github.io/openai-agents-js/guides/mcp/#optional-approval-flow).
|
||||
- Also see [MCP Approval Requests from OpenAI](https://platform.openai.com/docs/guides/tools-remote-mcp#approvals).
|
||||
- Also see [Microsoft Foundry MCP Approvals](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/model-context-protocol-samples?pivots=rest#submit-your-approval).
|
||||
- Also see [Azure AI Foundry MCP Approvals](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/model-context-protocol-samples?pivots=rest#submit-your-approval).
|
||||
- Also see [MCP Elicitation requests](https://modelcontextprotocol.io/specification/draft/client/elicitation)
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
@@ -1125,7 +1125,7 @@ Naming (Python): N/A (Composable Components)
|
||||
Supports: N
|
||||
Observation: No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution.
|
||||
|
||||
No specific code examples available for interception.
|
||||
For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception.
|
||||
|
||||
#### Smolagents (Hugging Face)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ This section describes different options for various aspects required to add lon
|
||||
|
||||
### 1. Methods for Working with Long-Running Operations
|
||||
|
||||
Based on the analysis of existing APIs that support long-running operations (such as OpenAI Responses, Microsoft Foundry Agents, and A2A),
|
||||
Based on the analysis of existing APIs that support long-running operations (such as OpenAI Responses, Azure AI Foundry Agents, and A2A),
|
||||
the following operations are used for working with long-running operations:
|
||||
- Common operations:
|
||||
- **Start Long-Running Execution**: Initiates a long-running operation and returns its Id.
|
||||
@@ -757,7 +757,7 @@ Some of them natively support resuming streaming from a specific point in the st
|
||||
| API | Can Resume Streaming | Model |
|
||||
|-------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| OpenAI Responses | Yes | StreamingResponseUpdate.**SequenceNumber** + GetResponseStreamingAsync(responseId, **startingAfter**, ct) |
|
||||
| Microsoft Foundry Agents | Emulated<sup>2</sup> | RunStep.**Id** + custom pseudo code: client.Runs.GetRunStepsAsync(...).AllStepsAfter(**stepId**) |
|
||||
| Azure AI Foundry Agents | Emulated<sup>2</sup> | RunStep.**Id** + custom pseudo code: client.Runs.GetRunStepsAsync(...).AllStepsAfter(**stepId**) |
|
||||
| A2A | Implementation dependent<sup>1</sup> | |
|
||||
|
||||
<sup>1</sup> The [A2A specification](https://github.com/a2aproject/A2A/blob/main/docs/topics/streaming-and-async.md#1-streaming-with-server-sent-events-sse)
|
||||
@@ -765,7 +765,7 @@ allows an A2A agent implementation to decide how to handle streaming resumption:
|
||||
a task is still active (and the server hasn't sent a final: true event for that phase), the client can attempt to reconnect to the stream using the tasks/resubscribe RPC method.
|
||||
The server's behavior regarding missed events during the disconnection period (e.g., whether it backfills or only sends new updates) is implementation-dependent._
|
||||
|
||||
<sup>2</sup> The Microsoft Foundry Agents API has an API to start a streaming run but does not have an API to resume streaming from a specific point in the stream.
|
||||
<sup>2</sup> The Azure AI Foundry Agents API has an API to start a streaming run but does not have an API to resume streaming from a specific point in the stream.
|
||||
However, it has non-streaming APIs to access already started runs, which can be used to emulate streaming resumption by accessing a run and its steps and streaming all the steps after a specific step.
|
||||
|
||||
#### Required Changes
|
||||
@@ -828,7 +828,7 @@ Sequence of updates from OpenAI Responses API to answer the question "What time
|
||||
| resp_2 | 10 | resp.output_item.done | - | InProgress | |
|
||||
| resp_2 | 11 | resp.completed | Completed | Completed | |
|
||||
|
||||
Sequence of updates from Microsoft Foundry Agents API to answer the question "What time is it?" using a function call:
|
||||
Sequence of updates from Azure AI Foundry Agents API to answer the question "What time is it?" using a function call:
|
||||
| Id | SN | UpdateKind | Run.Status | Step.Status | Message.Status | ChatResponseUpdate.Status | Description |
|
||||
|--------|---------|-------------------|----------------|-------------|-----------------|---------------------------|---------------------------------------------------|
|
||||
| run_1 | - | RunCreated | Queued | - | - | Queued | |
|
||||
@@ -852,7 +852,7 @@ Sequence of updates from Microsoft Foundry Agents API to answer the question "Wh
|
||||
|
||||
To support long-running operations, the following values need to be returned by the GetResponseAsync and GetStreamingResponseAsync methods:
|
||||
- `ResponseId` - identifier of the long-running operation or an entity representing it, such as a task.
|
||||
- `ConversationId` - identifier of the conversation or thread the long-running operation is part of. Some APIs, like Microsoft Foundry Agents, use
|
||||
- `ConversationId` - identifier of the conversation or thread the long-running operation is part of. Some APIs, like Azure AI Foundry Agents, use
|
||||
this identifier together with the ResponseId to identify a run.
|
||||
- `SequenceNumber` - identifier of an update within a stream of updates. This is required to support streaming resumption by the GetStreamingResponseAsync method only.
|
||||
- `Status` - status of the long-running operation: whether it is queued, running, failed, cancelled, completed, etc.
|
||||
@@ -1089,7 +1089,7 @@ public class ChatOptions
|
||||
|
||||
##### 6.1.5 Continuation Token of a Custom Type
|
||||
|
||||
The option is similar to the "6.1.3 Continuation Token of System.ClientModel.ContinuationToken Type" option but suggests using a
|
||||
The option is similar the the "6.1.3 Continuation Token of System.ClientModel.ContinuationToken Type" option but suggests using a
|
||||
custom type for the continuation token instead of the `System.ClientModel.ContinuationToken` type.
|
||||
|
||||
**Pros**
|
||||
@@ -1203,7 +1203,7 @@ response = await agent.CancelRunAsync(response.ResponseId, new AgentCancelRunOpt
|
||||
In case an agent supports either or both cancellation and deletion of long-running operations, it will override the corresponding methods.
|
||||
Otherwise, it won't override them, and the base implementations will return null by default.
|
||||
|
||||
Some agents, for example Microsoft Foundry Agents, require the thread identifier to cancel a run. To accommodate this requirement, the `CancelRunAsync` method
|
||||
Some agents, for example Azure AI Foundry Agents, require the thread identifier to cancel a run. To accommodate this requirement, the `CancelRunAsync` method
|
||||
accepts an optional `AgentCancelRunOptions` parameter that allows callers to specify the thread associated with the run they want to cancel.
|
||||
|
||||
```csharp
|
||||
@@ -1574,7 +1574,7 @@ the thread is provided with background operations consistently for all runs.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Microsoft Foundry Agents</summary>
|
||||
<summary>Azure AI Foundry Agents</summary>
|
||||
|
||||
- Create a thread and run the agent against it and wait for it to complete using polling:
|
||||
```csharp
|
||||
|
||||
@@ -34,11 +34,11 @@ Key changes:
|
||||
1. **New `agent-framework-openai` package** with dependencies on `agent-framework-core`, `openai`, and `packaging` only.
|
||||
2. **Class renames**: `OpenAIResponsesClient` → `OpenAIChatClient` (Responses API), `OpenAIChatClient` → `OpenAIChatCompletionClient` (Chat Completions API). Old names remain as deprecated aliases.
|
||||
3. **Deprecated classes**: `OpenAIAssistantsClient`, all `AzureOpenAI*Client` classes, `AzureAIClient`, `AzureAIAgentClient`, and `AzureAIProjectAgentProvider` are marked deprecated.
|
||||
4. **New `FoundryChatClient`** in azure-ai for Microsoft Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
|
||||
4. **New `FoundryChatClient`** in azure-ai for Azure AI Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
|
||||
5. **All deprecated `AzureOpenAI*` classes** consolidated into a single file (`_deprecated_azure_openai.py`) in the azure-ai package for clean future deletion.
|
||||
6. **Core's `agent_framework.openai` and `agent_framework.azure` namespaces** become lazy-loading gateways, preserving backward-compatible import paths while removing hard dependencies.
|
||||
7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_CHAT_MODEL_ID` / `OPENAI_CHAT_COMPLETION_MODEL_ID`).
|
||||
8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Microsoft Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
|
||||
8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Azure AI Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
|
||||
|
||||
### Foundry Agent Design: `FoundryAgentClient` vs `FoundryAgent`
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ consulted: Pratyush Mishra, Shivam Shrivastava, Manni Arora (Centrica eval scena
|
||||
informed: Agent Framework team, Foundry Evals team
|
||||
---
|
||||
|
||||
# Agent Evaluation Architecture with Microsoft Foundry Integration
|
||||
# Agent Evaluation Architecture with Azure AI Foundry Integration
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Microsoft Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.
|
||||
Azure AI Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.
|
||||
|
||||
However, using Foundry Evals with an agent-framework agent today requires significant manual effort. Developers must:
|
||||
|
||||
@@ -445,7 +445,7 @@ These factorings produce different scores for the same conversation. The framewo
|
||||
|
||||
### Azure AI: FoundryEvals
|
||||
|
||||
`Evaluator` implementation backed by Microsoft Foundry:
|
||||
`Evaluator` implementation backed by Azure AI Foundry:
|
||||
|
||||
```python
|
||||
class FoundryEvals:
|
||||
@@ -812,4 +812,4 @@ public sealed class EvalItem
|
||||
|
||||
## More Information
|
||||
|
||||
- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Microsoft Foundry evaluation overview
|
||||
- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Azure AI Foundry evaluation overview
|
||||
|
||||
@@ -43,11 +43,6 @@ FIDES (Flow Integrity Deterministic Enforcement System) is a label-based securit
|
||||
3. **Variable Indirection** — `ContentVariableStore` and `VariableReferenceContent` for physical isolation of untrusted content from the LLM context.
|
||||
4. **Quarantined Execution** — `quarantined_llm` and `inspect_variable` tools for isolated processing of untrusted data with audit logging.
|
||||
|
||||
In addition, remote MCP integrations are secured through two mechanisms:
|
||||
|
||||
- **Hint-based tool auto-labeling**: MCP `ToolAnnotations` (`readOnlyHint`, `openWorldHint`, etc.) are mapped to FIDES tool properties (`source_integrity`, `accepts_untrusted`, `max_allowed_confidentiality`).
|
||||
- **Server `_meta.ifc` result labels**: MCP result metadata is parsed into per-item `security_label` values, so provider-supplied IFC labels are enforced by middleware.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, because it provides deterministic security guarantees about what untrusted content can influence.
|
||||
@@ -122,13 +117,6 @@ Monitor agent behavior and block suspicious actions post-facto.
|
||||
- Uses existing `FunctionMiddleware` base class.
|
||||
- Attaches labels via `additional_properties` (no schema changes).
|
||||
- Leverages `SerializationMixin` for label persistence.
|
||||
- Integrates MCP hint/result metadata through `additional_properties` keys (`max_allowed_confidentiality`, `source_integrity`, `__mcp_result_meta__`) without transport-specific policy code in core middleware.
|
||||
|
||||
### MCP-Specific Security Notes
|
||||
|
||||
- `SecureMCPToolProxy` applies `apply_mcp_security_labels(...)` automatically when connecting an MCP tool or URL.
|
||||
- For servers like the GitHub MCP server (with `X-MCP-Features: ifc_labels`), `_meta.ifc` labels are considered authoritative for per-result label assignment.
|
||||
- Tools that are not explicitly `readOnlyHint=True` are treated as potential sinks and default to `max_allowed_confidentiality=PUBLIC` to prevent exfiltration.
|
||||
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
@@ -9,7 +9,7 @@ deciders: evmattso
|
||||
|
||||
## What is the goal of this feature?
|
||||
|
||||
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in a Microsoft Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
|
||||
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in an Azure AI Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
|
||||
|
||||
A user who has configured a toolbox in the Foundry portal (or via the raw SDK) should be able to load it into an agent with a single call:
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
status: superseded by [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md)
|
||||
contact: rogerbarreto
|
||||
date: 2026-06-29
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Hosted session identity context for Foundry Hosting
|
||||
|
||||
> **Superseded by [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md).** `Azure.AI.AgentServer.*` 2.0.0 (responses protocol `2.0.0`) replaced `ResponseContext.Isolation` (`UserIsolationKey` / `ChatIsolationKey`, headers `x-agent-user-isolation-key` / `x-agent-chat-isolation-key`) with `ResponseContext.PlatformContext` (`UserIdKey` / `CallId`, headers `x-agent-user-id` / `x-agent-foundry-call-id`). The chat isolation key was removed and `HostedSessionContext` is now user-only. This ADR is retained as the historical record of the original design.
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Server-hosted Foundry agents need a way to scope per-user state (most notably `FoundryMemoryProvider` memories) by the end user that initiated the request. The Foundry platform already injects `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers on every Responses request, but the agent-framework hosting layer did not surface those values to `AIContextProvider` instances. The provider's `stateInitializer` only received an `AgentSession?` with no identity attached, so per-user scoping was impossible without out-of-band plumbing.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Memory and any future user-private context must be partitioned per end user without per-sample boilerplate.
|
||||
- The identity must be **read-only** from the perspective of `AIContextProvider`s, so a buggy or hostile provider cannot escalate or leak across users.
|
||||
- The persisted session must validate against the live request on every resume to defend against session-id leak and in-process tampering.
|
||||
- The change must work for every existing hosted-agent type (`ChatClientAgent`, `FoundryAgent`, future ones) without per-type refactoring of cast-heavy code paths in `Microsoft.Agents.AI`.
|
||||
- Local Docker debugging must remain possible when the platform headers are absent.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **`HostedSessionContext` stored in `AgentSessionStateBag`, exposed via a public read accessor and an `internal` setter.** Hosting writes once on session creation and validates on every resume.
|
||||
2. **Specialised `HostedAgentSession : AgentSession` wrapper** that carries `UserId`/`ChatId` properties, with `GetService<ChatClientAgentSession>()` as the unwrap escape hatch.
|
||||
3. **New property on `AgentSession` base class** (`HostedSessionContext? HostedContext { get; internal set; }`).
|
||||
4. **AsyncLocal middleware** that reads the headers and stuffs them into a per-request `AsyncLocal<HostedSessionContext>` consumed by the provider.
|
||||
|
||||
For the source of identity:
|
||||
- A. The platform-injected `IsolationContext` exposed by `ResponseContext.Isolation` (typed `UserIsolationKey`/`ChatIsolationKey`).
|
||||
- B. The OpenAI Responses spec's top-level `request.User` field.
|
||||
- C. A custom HTTP header `x-client-user`.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Option 1** was chosen for the storage shape, sourced from **Option A** (`ResponseContext.Isolation`).
|
||||
|
||||
Rationale:
|
||||
|
||||
- **Wrapper rejected (Option 2).** `ChatClientAgentSession` is `sealed` and `ChatClientAgent` rejects any other session type via direct `is not ChatClientAgentSession` checks at multiple call sites. Wrapping would force non-trivial refactors across `Microsoft.Agents.AI` and a corresponding repeat for every other agent type.
|
||||
- **Base-class property rejected (Option 3).** Leaks "hosted" semantics into the universal `AgentSession` abstraction used by Durable, A2A, and CopilotStudio agents that have no notion of a hosted user.
|
||||
- **AsyncLocal rejected (Option 4).** Surfaces the concept only locally, requires every consumer to re-implement the bridge, and cannot be enforced as read-only.
|
||||
- **`request.User` rejected (Option B).** Set by the caller, not the platform. Forging it client-side trivially defeats per-user partitioning.
|
||||
- **`x-client-user` rejected (Option C).** Non-standard, requires custom HTTP plumbing, and duplicates the platform-provided isolation contract.
|
||||
|
||||
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
|
||||
|
||||
| Type | Visibility | Purpose |
|
||||
|---|---|---|
|
||||
| `HostedSessionContext` | public sealed | Captures `UserId` and `ChatId` (both required, non-whitespace). |
|
||||
| `HostedSessionContextExtensions.GetHostedContext` | public | Read accessor for `AIContextProvider`s. |
|
||||
| `HostedSessionContextExtensions.SetHostedContext` | internal | Writer reserved for the hosting assembly. Backed by `AgentSessionStateBag` under a well-known key for serialisation. |
|
||||
| `HostedSessionIsolationKeyProvider` (abstract) | public | DI-resolvable factory. Async signature: `ValueTask<HostedSessionContext?> GetKeysAsync(ResponseContext, CreateResponse, CancellationToken)`. |
|
||||
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Default implementation. Maps `context.Isolation.UserIsolationKey` and `context.Isolation.ChatIsolationKey`. Returns `null` when either is absent. |
|
||||
|
||||
Behaviour added to `AgentFrameworkResponseHandler.CreateAsync`:
|
||||
|
||||
1. Resolve `HostedSessionIsolationKeyProvider` from DI; fall back to `PlatformHostedSessionIsolationKeyProvider`.
|
||||
2. Call `GetKeysAsync(context, request, cancellationToken)`. A `null` result throws `InvalidOperationException` (becomes 500). A null/whitespace `UserId` or `ChatId` is rejected by `HostedSessionContext`'s constructor.
|
||||
3. Branch on the **session's existing context**, not on whether a `conversation_id` was supplied:
|
||||
- **No session (`session is null`):** nothing to stamp; skip.
|
||||
- **Session present but un-stamped (`GetHostedContext() is null`):** treat as fresh. This covers both newly-created sessions and pre-existing sessions whose `conversation_id` was provisioned externally (e.g. via `conversations.CreateProjectConversationAsync()`) before the first hosted-agent request. Stamp the resolved identity now.
|
||||
- **Session present with stamped context:** strict resume. The persisted `UserId` and `ChatId` must equal the resolved values exactly. Mismatch throws `ResponsesApiException` with status 403 and body `Hosted session identity context mismatch`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Per-user memory partitioning works out of the box for any agent that consumes a `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` configured to read `session.GetHostedContext().UserId`.
|
||||
- Cross-user session-id leak and in-process tampering of the persisted identity both surface as a 403 with a deliberately uninformative body.
|
||||
- The identity is opaque to the framework, matching the platform's semantics. The framework never inspects user identity; the `IsolationContext` keys are pre-partitioned per agent.
|
||||
|
||||
Negative:
|
||||
|
||||
- Every existing hosted sample fails locally without a `HostedSessionIsolationKeyProvider` registered, because the platform headers are absent outside the platform. Mitigated by shipping `Hosted_Shared_Contributor_Setup` with `DevTemporaryLocalSessionIsolationKeyProvider` and `AddDevTemporaryLocalContributorSetup`, and migrating all 9 existing responses samples.
|
||||
- An attacker who can plant an un-stamped session under a victim's `conversation_id` *before* the victim's first hosted-agent request would be stamped with the attacker's identity on that first request. This is not a regression vs. behaviour without this contract, and is mitigated in practice because the `conversation_id` namespace is allocated by the platform per project. Once a session is stamped, the strict equality check fully defends the resume path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-request `User` field on `CreateResponse` is intentionally not consumed; only the platform `IsolationContext` headers carry trustworthy identity.
|
||||
- Generic (non-Foundry) hosting layers can re-define an equivalent type if needed; nothing in this ADR is moved into `Microsoft.Agents.AI.Hosting` because `Microsoft.Agents.AI.Foundry.Hosting` does not depend on it.
|
||||
- HMAC tamper signatures over the persisted context are not implemented; comparison against `ResponseContext.Isolation` on every request is sufficient because the platform sets those headers at the trust boundary.
|
||||
@@ -1,524 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-30
|
||||
deciders: eavanvalkenburg
|
||||
consulted: rogerbarreto, moonbox3
|
||||
---
|
||||
|
||||
# Python protocol helpers and optional execution state
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Agent Framework needs to help applications expose agents and workflows over external protocols such as OpenAI
|
||||
Responses, Telegram, Activity Protocol, and future transports.
|
||||
|
||||
FastAPI, Starlette, Azure Functions, Django, Telegram SDKs, Bot Framework SDKs, and other app frameworks already own
|
||||
route registration, dependency injection, middleware, authentication, background tasks, lifecycle, and native client
|
||||
calls. Agent Framework should not duplicate those surfaces unless a specific hosting environment requires it.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keep the released surface small enough to explain without first teaching a channel framework.
|
||||
- Provide reusable Agent Framework run translation that works with FastAPI, Django, and other web frameworks.
|
||||
- Let app/framework code own route declaration, auth, middleware, native SDK clients, command handling, and background
|
||||
work.
|
||||
- Keep stateful execution support explicit: session lookup/storage and workflow checkpoint lookup/storage may still need
|
||||
a small AF-owned home.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Create protocol-specific hosts.
|
||||
2. Ship a full host/channel framework with route contribution and channel hooks.
|
||||
3. Ship protocol conversion helpers plus optional execution state.
|
||||
|
||||
### 1. Create protocol-specific hosts
|
||||
|
||||
- Good: no new shared abstraction.
|
||||
- Neutral: each protocol host can evolve independently.
|
||||
- Bad: every package reinvents AF input/result mapping, session-key conventions, and stateful execution helpers.
|
||||
|
||||
### 2. Ship a full host/channel framework
|
||||
|
||||
- Good: one object can assemble routes, channels, session handling, hooks, and lifecycle callbacks.
|
||||
- Good: app code using the supported host shape can be short.
|
||||
- Bad: the framework owns concerns already handled by web frameworks, protocol SDKs and/or other services.
|
||||
- Bad: users must understand `Channel`, contribution, hook, and host-dispatch concepts before they can see how a request
|
||||
becomes `agent.run(...)`.
|
||||
- Bad: the abstraction is hard to reuse outside the chosen web framework.
|
||||
|
||||
### 3. Ship protocol helpers plus optional execution state
|
||||
|
||||
- Good: protocol packages provide the Agent Framework run value directly: `<protocol>_to_run(...)` and
|
||||
`<protocol>_from_run(...)` style helpers.
|
||||
- Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code.
|
||||
- Good: helper functions can be tested without a web framework app or host pipeline.
|
||||
- Good: small state objects can still own target-coupled state: `AgentState` pairs an agent target with a `SessionStore`,
|
||||
and `WorkflowState` resolves a workflow target while reusing the existing `CheckpointStorage` abstraction.
|
||||
- Good: provides maximum configurability in handling input and outputs (outside of the conversions)
|
||||
- Bad: building a first iteration of a new Host is more verbose.
|
||||
- Bad: samples show more explicit route/client code than a fully assembled channel host.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **3. Ship protocol helpers plus optional execution state**.
|
||||
|
||||
Protocol packages own:
|
||||
|
||||
- parsing protocol-native input into Agent Framework run input and options;
|
||||
- rendering `AgentResponse`, `AgentResponseUpdate`, workflow results, or workflow updates back into protocol-native
|
||||
response/event payloads;
|
||||
- protocol-specific isolation/session id helper functions when useful, such as `telegram_session_id(update)`;
|
||||
- protocol-specific typing/update event helpers where the protocol has a native concept.
|
||||
|
||||
Application or web-framework code owns:
|
||||
|
||||
- HTTP route declaration and route grouping;
|
||||
- dependency injection;
|
||||
- authentication and authorization;
|
||||
- middleware;
|
||||
- background tasks and webhook acknowledgement policy;
|
||||
- native protocol SDK clients and outbound calls;
|
||||
- command registration and command dispatch;
|
||||
- request/response status codes and framework-specific error handling;
|
||||
- choosing the isolation/session id source for the current deployment and route.
|
||||
|
||||
The application builder can make the server exactly as they see fit, but this is outside the responsibilities of this proposed scheme.
|
||||
This might include implementing other known API surfaces from vendors like OpenAI, such as creating conversations, vector stores, deleting things, etc.
|
||||
If they want they can build the full OpenAI API, but it will include code that does not rely on agent-framework-hosting, which is fine.
|
||||
They are responsible for what they expose.
|
||||
|
||||
The optional execution-state helpers, if provided, are limited to shared execution state:
|
||||
|
||||
- `AgentState`: one `SupportsAgentRun`-compatible target plus a `SessionStore`;
|
||||
- `WorkflowState`: one `Workflow`, `WorkflowBuilder`-shaped builder, orchestration builder, or workflow factory;
|
||||
- `SessionStore`: plain async storage (`get` / `set` / `delete`) by an app-selected id.
|
||||
|
||||
The store does not create sessions. `AgentState` provides the target-aware `get_or_create_session(...)` helper because
|
||||
only the state object has both the store and the resolved agent target. Workflow checkpointing should use the existing
|
||||
`CheckpointStorage` abstraction directly; app/state code may keep a small cursor (`session_id -> checkpoint_id`) when it
|
||||
needs to resume a workflow for a session.
|
||||
|
||||
These objects are **not** app objects, channel registries, or route owners. They do not own FastAPI/Starlette setup,
|
||||
route contribution, protocol dispatch, command projection, or native SDK calls.
|
||||
|
||||
### Helper naming and families
|
||||
|
||||
Helpers should be protocol-specific, not generic. Avoid a generic `protocol_to_run(...)` name in public samples because it
|
||||
hides the protocol-specific contract behind a second abstraction.
|
||||
|
||||
Protocol packages should consider these helper families. This table is a set of examples, not a required protocol or
|
||||
checklist. Not every protocol needs every helper, but when a protocol has the concept the naming should stay consistent:
|
||||
|
||||
| Helper family | Shape | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Run conversion | `<protocol>_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. |
|
||||
| Final rendering | `<protocol>_from_run(...)` | Convert a final `AgentResponse` / workflow result into protocol-native response payloads or operations. |
|
||||
| Stream rendering | `<protocol>_from_streaming_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. |
|
||||
| Session id extraction | `<protocol>_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. |
|
||||
| Command/action parsing | `<protocol>_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. |
|
||||
|
||||
Examples:
|
||||
|
||||
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
|
||||
`responses_session_id(...)`;
|
||||
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
|
||||
`telegram_session_id(...)`, `telegram_command(...)`;
|
||||
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
|
||||
- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`.
|
||||
|
||||
The app still owns what a parsed command means. For example, a Telegram `/new`, Discord slash command, Bot Framework
|
||||
command activity, or A2A cancellation/request action may parse through a command/action helper, but the route or SDK
|
||||
handler decides whether that command clears a session, cancels a task, calls an agent, or is ignored.
|
||||
|
||||
Additional helper functions can be protocol-specific when the concept is not broadly shared. Examples include
|
||||
`telegram_chat_id(...)`, `telegram_callback_query_id(...)`, `telegram_media_file_id(...)`,
|
||||
`discord_interaction_id(...)`, `a2a_task_id(...)`, `a2a_context_id(...)`, and MCP tool/prompt/resource helpers. These
|
||||
helpers should still stay side-effect-free: they extract, normalize, or describe protocol data, while app/native SDK code
|
||||
performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers
|
||||
handlers.
|
||||
|
||||
### Security responsibilities for application builders
|
||||
|
||||
The application builder owns the trust boundary. Protocol helper packages can parse native payloads and expose candidate
|
||||
ids or operations, but they do not authenticate callers, authorize access to state, or decide which side effects are
|
||||
allowed.
|
||||
|
||||
Application code that uses these helpers are responsible for (this means that we advice you to think through these topics,
|
||||
but ultimately, the choice of which controls are needed for the intended use case is up to the application builder):
|
||||
|
||||
- authenticate the caller through the app's normal mechanism before using protocol-provided ids;
|
||||
- authorize any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading
|
||||
state for it;
|
||||
- bind externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before using
|
||||
them as `SessionStore` keys or checkpoint cursor keys;
|
||||
- treat `<protocol>_session_id(...)` results as untrusted candidate keys until that ownership check has passed;
|
||||
- keep platform-provided isolation helpers fail-closed outside their trusted hosting environment;
|
||||
- authorize command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them;
|
||||
- opt in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider;
|
||||
- persist post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization has
|
||||
updated that state.
|
||||
|
||||
For Foundry specifically, helpers may read values established by Foundry hosting middleware, but must not treat raw
|
||||
request headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that
|
||||
non-Foundry requests do not accept spoofable isolation headers as platform-provided keys.
|
||||
|
||||
For workflow checkpointing, the checkpoint boundary must be at least as specific as the authorized session/tenant
|
||||
boundary. A shared storage lookup such as "latest checkpoint for workflow name" is safe only when the storage is already
|
||||
scoped to the authorized session. In a shared durable store, map the authorized `session_id` to a checkpoint id or other
|
||||
cursor and load that specific checkpoint.
|
||||
|
||||
### Session continuity
|
||||
|
||||
Session continuity remains explicit. Run parsing and isolation/session id selection are separate operations because
|
||||
isolation can come from more than one source:
|
||||
|
||||
- protocol input, such as OpenAI Responses `previous_response_id`, a Telegram chat id, or an Activity conversation id;
|
||||
- running environment, such as Foundry Hosted Agents user/chat isolation context;
|
||||
- app-specific trusted middleware or route state.
|
||||
|
||||
The app chooses which helper to call for that route and deployment. For example:
|
||||
|
||||
- `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous
|
||||
response id or a `conv_*` conversation id when present;
|
||||
- `telegram_session_id(update, bot_id=...)` from `agent-framework-hosting-telegram`, which uses the bot and sender for
|
||||
private chats and the bot and chat for shared group sessions;
|
||||
- `activity_session_id(activity)`, `discord_session_id(interaction_or_message)`, or
|
||||
`a2a_session_id(request_context)` from their respective protocol packages;
|
||||
- `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`.
|
||||
|
||||
Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the
|
||||
trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key.
|
||||
|
||||
The application builder is also responsible for deciding whether the hosting environment is **persistent** (for example,
|
||||
a long-running container or web app) or **transient** (for example, Azure Functions, Foundry Hosted Agents, or any
|
||||
environment where process memory is not a reliable continuity boundary). That decision controls which state mechanisms are
|
||||
safe to use:
|
||||
|
||||
- persistent single-process apps may use in-memory state for local development or simple deployments, while still needing
|
||||
durable state for multi-replica continuity;
|
||||
- transient apps must not rely on in-memory `SessionStore` state between calls and need a durable session store or a
|
||||
service-owned continuation id;
|
||||
- workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable
|
||||
`session_id -> checkpoint_id` cursor because in-process workflow state and in-memory checkpoint cursors do not survive
|
||||
transient execution.
|
||||
|
||||
A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent
|
||||
target and creates the session on first use. Reads return independent working copies so running from one continuation
|
||||
point does not mutate the stored snapshot or another simultaneous branch:
|
||||
|
||||
For agent targets:
|
||||
|
||||
```python
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(messages, session=session, options=options)
|
||||
```
|
||||
|
||||
If the protocol mints a new continuation id as part of the response being created (for example, OpenAI Responses
|
||||
`resp_*` ids), store the **post-run** session explicitly under that new id:
|
||||
|
||||
```python
|
||||
session = await state.get_or_create_session(previous_response_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(messages, session=session, options=options)
|
||||
await state.set_session(response_id, session)
|
||||
```
|
||||
|
||||
`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call
|
||||
belongs after the run, not before it.
|
||||
|
||||
Response ids are immutable continuation points, so simultaneous callers can branch from one `previous_response_id` and
|
||||
store their completed sessions under different new response ids. A stable `conversation_id` is a mutable head: the app
|
||||
must explicitly update it after the run and provide single-writer coordination. The hosting state helper does not lock
|
||||
an entire run or resolve concurrent updates to that stable key.
|
||||
|
||||
The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any
|
||||
externally supplied key before using it.
|
||||
|
||||
### Workflow checkpoints
|
||||
|
||||
Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with checkpoint
|
||||
state, but it should not wrap or replace the existing `CheckpointStorage` abstraction. Apps should pass the actual
|
||||
`CheckpointStorage` they want the workflow to use. If an app needs per-session resume, it can keep a small cursor from
|
||||
authorized `session_id` to `checkpoint_id` (or an equivalent store-specific resume token).
|
||||
|
||||
Workflow runs do not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default. The
|
||||
runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. App/state code that owns the storage can
|
||||
observe the latest id by querying the storage after a run, for example
|
||||
`await storage.get_latest(workflow_name=target.name)`.
|
||||
|
||||
For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the
|
||||
workflow through the state object's target:
|
||||
|
||||
```python
|
||||
# session_id must already be authenticated and authorized for this caller
|
||||
target = await state.get_target()
|
||||
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
|
||||
if latest is not None:
|
||||
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
|
||||
```
|
||||
|
||||
If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to
|
||||
`workflow.run(...)`:
|
||||
|
||||
```python
|
||||
# session_id must already be authenticated and authorized for this caller
|
||||
target = await state.get_target()
|
||||
checkpoint_id = await checkpoint_cursor_store.get(session_id)
|
||||
if checkpoint_id is None:
|
||||
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
|
||||
else:
|
||||
result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage)
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
|
||||
if latest is not None:
|
||||
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
|
||||
```
|
||||
|
||||
`workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer.
|
||||
Protocol helper packages should not own checkpoint layout, route lifecycle, or durable execution.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
The following remain outside the v1 protocol-helper contract. Some are deliberately app-owned in v1; others are possible
|
||||
future framework work only after a separate design.
|
||||
|
||||
### App-owned in v1
|
||||
|
||||
The app builder owns these concerns with normal web-framework, SDK, platform, or application code:
|
||||
|
||||
- authentication, authorization policy, and allowlists;
|
||||
- deciding whether identities across protocols map to the same `session_id`;
|
||||
- non-originating sends using native SDK clients;
|
||||
- background work, durable execution, retry, and replay when app code owns the work;
|
||||
- routing between multiple agents.
|
||||
|
||||
This is easier in the protocol-helper model than it was in the host/channel model: app code already owns the native SDK
|
||||
clients, route handlers, authenticated caller context, session id selection, and outbound send calls. An app can link
|
||||
channels by choosing the same authorized `session_id` for multiple protocols, and can do non-originating delivery by
|
||||
calling the destination protocol's native client directly. That does not make a reusable framework feature safe by
|
||||
default; it just means the app-specific version no longer has to fight a host abstraction.
|
||||
|
||||
### Future framework work
|
||||
|
||||
The following require a reviewed identity, storage, delivery, replay, and observability model before becoming reusable
|
||||
framework features:
|
||||
|
||||
- reusable cross-channel identity linking;
|
||||
- framework-owned proactive or non-originating delivery;
|
||||
- fan-out, multicast, selected-channel, active-channel, or all-linked delivery;
|
||||
- framework-owned delivery observability, dead-letter handling, and replay semantics;
|
||||
- cross-channel confidentiality and link policy.
|
||||
|
||||
These possible framework enhancements are tracked by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are
|
||||
not prerequisites for shipping or using the v1 protocol-helper surface. ADR-0028 was written against the earlier
|
||||
host/channel framing and must be revised to align with this protocol-helper and execution-state boundary before those
|
||||
enhancements are implemented.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- The released surface is smaller and easier to inspect: helpers plus state, not a channel framework.
|
||||
- Protocol helpers can be used from FastAPI, Starlette, Azure Functions, Django, CLI tools, tests, or native SDK webhook
|
||||
handlers.
|
||||
- App authors can use the authentication, dependency injection, lifecycle, and background-task tools they already know.
|
||||
- Session continuity stays explicit and debuggable.
|
||||
- Workflow checkpointing can still be centralized if needed without making protocol packages own routing.
|
||||
|
||||
Negative:
|
||||
|
||||
- Multi-protocol samples include explicit route/client code.
|
||||
- Apps that want a batteries-included ASGI app must write or depend on an app-specific wrapper.
|
||||
- Existing unreleased code and docs that mention channels, contribution, or hooks must be revised before release.
|
||||
|
||||
## More Information
|
||||
|
||||
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md). That ADR still uses
|
||||
some earlier host/channel terminology and must be aligned before implementation work starts.
|
||||
|
||||
## Appendix: Developer experience sketch
|
||||
|
||||
The examples below are sketches, not runtime-ready sample code. They show the minimum shape a developer would need to
|
||||
build: where protocol helpers are called, where app-owned auth/authorization belongs, where state is loaded/stored, and
|
||||
where native framework code remains in charge.
|
||||
|
||||
### Optional execution state
|
||||
|
||||
`AgentState` and `WorkflowState` stay small: they are target-specific state holders, not app hosts.
|
||||
|
||||
```python
|
||||
from typing import Protocol
|
||||
|
||||
from agent_framework import AgentSession, SupportsAgentRun, Workflow
|
||||
|
||||
|
||||
class SupportsBuild(Protocol):
|
||||
def build(self) -> Workflow: ...
|
||||
|
||||
|
||||
class SessionStore:
|
||||
async def get(self, session_id: str) -> AgentSession | None: ...
|
||||
async def set(self, session_id: str, session: AgentSession) -> None: ...
|
||||
async def delete(self, session_id: str) -> None: ...
|
||||
|
||||
|
||||
class CheckpointCursorStore:
|
||||
async def get(self, session_id: str) -> str | None: ...
|
||||
async def set(self, session_id: str, checkpoint_id: str) -> None: ...
|
||||
async def delete(self, session_id: str) -> None: ...
|
||||
|
||||
|
||||
class AgentState:
|
||||
def __init__(self, target: SupportsAgentRun, *, session_store: SessionStore | None = None) -> None: ...
|
||||
async def get_target(self) -> SupportsAgentRun: ...
|
||||
async def get_or_create_session(self, session_id: str) -> AgentSession: ...
|
||||
async def set_session(self, session_id: str, session: AgentSession) -> None: ...
|
||||
|
||||
|
||||
class WorkflowState:
|
||||
def __init__(self, target: Workflow | SupportsBuild) -> None: ...
|
||||
async def get_target(self) -> Workflow: ...
|
||||
```
|
||||
|
||||
`WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with
|
||||
`build() -> Workflow`. That structurally covers `WorkflowBuilder` and the builders in `agent_framework_orchestrations`
|
||||
without making `agent-framework-hosting` depend on the orchestration package.
|
||||
|
||||
### Responses-only route
|
||||
|
||||
This sketch shows the intended Responses-only shape. The protocol package owns the Agent Framework run conversion helpers and
|
||||
response-id minting details; the application owns FastAPI routing, auth, policy adjustment, and response construction.
|
||||
|
||||
```python
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agent_framework import Agent, ResponseStream
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue]
|
||||
from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_from_streaming_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue]
|
||||
from fastapi import Body, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
app = FastAPI()
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Assistant",
|
||||
instructions="Be concise and helpful.",
|
||||
)
|
||||
state = AgentState(agent)
|
||||
|
||||
|
||||
@app.post("/responses")
|
||||
async def responses(body: dict = Body(...), x_api_key: str | None = Header(default=None)) -> JSONResponse | StreamingResponse:
|
||||
if x_api_key != os.environ["RESPONSES_API_KEY"]:
|
||||
raise HTTPException(status_code=401, detail="bad api key")
|
||||
|
||||
# parse the request body into a set of AF objects
|
||||
run = responses_to_run(body)
|
||||
# get the candidate session id from the body
|
||||
# can be a resp_* for previous_response_id or a conv_* for a conversation
|
||||
candidate_session_id = responses_session_id(body)
|
||||
# create a new response_id for this run
|
||||
response_id = create_response_id()
|
||||
|
||||
# the developer can make any adjustments to the request, i.e.:
|
||||
run["options"]["store"] = False
|
||||
run["options"].pop("model", None)
|
||||
# the options here are of the shape defined by the ChatClient/Agent
|
||||
|
||||
# load the session (or create a new one) - this is optional
|
||||
# verify this caller owns candidate_session_id before loading it; API-key auth
|
||||
# alone does not prove ownership of a caller-supplied resp_* or conv_* id
|
||||
session_id = candidate_session_id or response_id
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
|
||||
if run["stream"]:
|
||||
stream = target.run(
|
||||
run["messages"],
|
||||
stream=True,
|
||||
session=session,
|
||||
options=run["options"],
|
||||
)
|
||||
async def stream_events() -> AsyncIterator[str]:
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id=response_id,
|
||||
session_id=candidate_session_id,
|
||||
):
|
||||
yield event
|
||||
# agent.run may update the session during stream finalization, so store the post-run session explicitly
|
||||
await state.set_session(response_id, session)
|
||||
|
||||
return StreamingResponse(stream_events(), media_type="text/event-stream")
|
||||
|
||||
result = await target.run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=run["options"],
|
||||
)
|
||||
# agent.run may update the session, so store the post-run session explicitly under the response id
|
||||
# this might also be skipped, if the app chooses to respect `store=False` policy
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
|
||||
|
||||
```
|
||||
|
||||
### Responses-only Django class-based view
|
||||
|
||||
The same helper surface can be used without FastAPI. A Django app owns URL routing, CSRF/auth policy, request parsing,
|
||||
and `JsonResponse` construction. In a real Django project this would live in the app's normal view module (for example
|
||||
`assistant/views.py`) and be routed from that app's `urls.py`; Django discovers it through its standard project/app
|
||||
layout, not through Agent Framework. This sketch shows the non-streaming path only; the streaming branch is the same
|
||||
state/finalization pattern shown in the FastAPI sketch and is omitted here to avoid duplicating it.
|
||||
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue]
|
||||
from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue]
|
||||
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse
|
||||
from django.views import View
|
||||
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Assistant",
|
||||
instructions="Be concise and helpful.",
|
||||
)
|
||||
state = AgentState(agent)
|
||||
|
||||
|
||||
class ResponsesView(View):
|
||||
async def post(self, request: HttpRequest) -> JsonResponse:
|
||||
if request.headers.get("x-api-key") != os.environ["RESPONSES_API_KEY"]:
|
||||
return HttpResponseForbidden("bad api key")
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("invalid json")
|
||||
|
||||
run = responses_to_run(body)
|
||||
candidate_session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
options = run["options"]
|
||||
# verify this caller owns candidate_session_id before loading it; API-key auth
|
||||
# alone does not prove ownership of a caller-supplied resp_* or conv_* id
|
||||
session_id = candidate_session_id or response_id
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=options,
|
||||
)
|
||||
await state.set_session(response_id, session)
|
||||
return JsonResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
|
||||
```
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Hosting linking and multicast enhancements
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0027](0027-hosting-channels.md) defines the minimal v1 hosting core: originating-channel responses, explicit `ChannelSession.isolation_key`, and no host-level identity linking, push, multicast, background delivery, or durable runners.
|
||||
|
||||
This ADR tracks the richer cross-channel behaviors that were removed from v1. These enhancements are **follow-up work** and are **not prerequisites** for shipping, using, or stabilizing the v1 host/channel core.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Cross-channel continuity must not create accidental cross-user, cross-tenant, or cross-channel data leaks.
|
||||
- Non-originating delivery must be observable, idempotent, retryable, and supportable.
|
||||
- Protocol payloads must remain channel-native while still being safe to persist and replay.
|
||||
- App authors need opt-in policy controls, not hidden defaults.
|
||||
- The enhancement stack should layer on top of the v1 host without reshaping the minimal channel contract.
|
||||
|
||||
## Enhancement Areas
|
||||
|
||||
The follow-up design should cover these capabilities together because they share identity, storage, delivery, and replay concerns:
|
||||
|
||||
- **Cross-channel identity linking** — a user can connect multiple `ChannelIdentity` values to one channel-neutral `isolation_key`.
|
||||
- **Authorization and allowlist policy** — channels or hosts can require verified identity, allow specific native identities or claims, and deny unknown callers.
|
||||
- **Non-originating response delivery** — a run can respond somewhere other than the request's originating protocol when explicitly configured.
|
||||
- **Active-channel routing** — delivery can target the most recently observed linked channel for an `isolation_key`.
|
||||
- **Multicast / all-linked delivery** — delivery can fan out to every linked channel or a selected set.
|
||||
- **Background runs and continuation tokens** — long-running requests can return immediately and complete later, with a polling/status fallback.
|
||||
- **Durable delivery runners** — delivery work can survive process restarts and support dead-letter handling.
|
||||
- **Retry and replay semantics** — delivery attempts are bounded, deduplicated, and safe to replay.
|
||||
- **Payload serialization** — channel-specific payloads can be persisted, redacted, versioned, and reconstructed without losing protocol fidelity.
|
||||
|
||||
Candidate API names from the broader design (`IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, `ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `InProcessTaskRunner`, `RetryPolicy`, `LinkPolicy`) remain design vocabulary for this ADR. They are not approved v1 APIs.
|
||||
|
||||
## Considered Options
|
||||
|
||||
### Option A — Leave all behavior to applications
|
||||
|
||||
Applications implement linking, authorization, push, retry, and serialization independently.
|
||||
|
||||
- Good: the hosting core stays very small.
|
||||
- Neutral: advanced apps can still build what they need.
|
||||
- Bad: every app must solve the same security and delivery problems, likely inconsistently.
|
||||
|
||||
### Option B — Add the full enhancement stack to v1
|
||||
|
||||
The first host release includes linking, authorization, active channel, multicast, background runs, durable runners, and codecs.
|
||||
|
||||
- Good: the original cross-channel experience is available immediately.
|
||||
- Neutral: samples can demonstrate rich end-to-end flows.
|
||||
- Bad: v1 becomes security-sensitive, storage-heavy, and harder to stabilize.
|
||||
|
||||
### Option C — Layer opt-in enhancement packages after v1
|
||||
|
||||
Ship the minimal host first, then add linking, authorization, and delivery packages behind explicit configuration.
|
||||
|
||||
- Good: v1 remains simple while leaving room for a reviewed, supportable enhancement stack.
|
||||
- Neutral: apps that need advanced delivery wait for follow-up packages.
|
||||
- Bad: the first release does not satisfy proactive or all-linked scenarios.
|
||||
|
||||
### Option D — Build only platform-specific integrations
|
||||
|
||||
Implement linking and proactive delivery separately in Telegram, Activity Protocol, Discord, and future channels.
|
||||
|
||||
- Good: each package can match its protocol exactly.
|
||||
- Neutral: some shared abstractions may emerge later.
|
||||
- Bad: cross-channel behavior becomes fragmented and hard to reason about.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Proposed direction: **Option C — layered opt-in enhancement packages after v1**.
|
||||
|
||||
The minimal host remains the foundation. Follow-up packages may add linking, authorization, delivery, and durable execution, but must be explicitly enabled and must pass the validation gates below before becoming part of the public contract.
|
||||
|
||||
## Safety Requirements
|
||||
|
||||
### Threat model
|
||||
|
||||
The design must account for:
|
||||
|
||||
- spoofed channel-native identities,
|
||||
- stolen or replayed link challenges,
|
||||
- cross-tenant or cross-confidentiality data leakage,
|
||||
- unsolicited proactive messages,
|
||||
- malicious payloads persisted for replay,
|
||||
- denial-of-service through fan-out or retry storms, and
|
||||
- privacy leakage through logs, metrics, or support tooling.
|
||||
|
||||
Required mitigations include verified identity claims where available, signed and expiring link challenges, explicit user consent, per-channel capability checks, default-deny policy options, tenant partitioning, and uninformative denial messages on shared channels.
|
||||
|
||||
### Idempotency and replay
|
||||
|
||||
Exactly-once delivery is not a realistic guarantee. The design must provide:
|
||||
|
||||
- stable run, continuation, and delivery-attempt identifiers,
|
||||
- channel-level idempotency keys where protocols support them,
|
||||
- bounded retry with jitter and explicit terminal states,
|
||||
- replay windows and expiration,
|
||||
- duplicate suppression for persisted attempts, and
|
||||
- clear semantics for "delivered", "accepted by platform", and "observed by user".
|
||||
|
||||
### Storage
|
||||
|
||||
Enhancement storage must stay distinct from v1 `AgentSession` history and workflow checkpoints unless an implementation deliberately backs them with the same physical store.
|
||||
|
||||
Stored data should be schema-versioned, minimized, encrypted or otherwise protected as appropriate, and partitioned by tenant/project. Link records, continuation records, active-channel state, delivery attempts, dead letters, and serialized payloads need independent TTL and deletion policies.
|
||||
|
||||
### Observability and support
|
||||
|
||||
The design must include structured logs, traces, and metrics for link attempts, authorization decisions, delivery scheduling, retries, replay, and dead-letter outcomes. Logs must avoid message content and sensitive identity claims by default. Operators need a way to inspect, revoke, replay, or purge stuck records safely.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
Before these enhancements are accepted:
|
||||
|
||||
- A reviewed threat model covers identity linking, authorization, non-originating delivery, multicast, and replay.
|
||||
- Cross-channel linking tests prove a verified identity can link two channels and that unlink/deny paths do not leak information.
|
||||
- Authorization tests cover native-id allowlists, verified-claim allowlists, default-deny behavior, and misconfiguration failures.
|
||||
- Delivery tests cover originating-only, specific-channel, active-channel, selected-channel, and all-linked routing.
|
||||
- Background/continuation tests cover polling fallback, cancellation or expiration, process restart, retry, and dead-letter behavior.
|
||||
- Codec tests prove payloads are versioned, redacted where needed, backward compatible, and rejected safely when unknown.
|
||||
- Multicast tests prove fan-out is bounded, independently retried, and idempotent per destination.
|
||||
- Observability tests or manual validation prove support operators can correlate a request to delivery attempts without exposing sensitive content.
|
||||
|
||||
## Relationship to ADR-0027
|
||||
|
||||
ADR-0027 remains valid without any of these enhancements. This ADR extends the hosting model only after the safety, storage, and support requirements above are satisfied.
|
||||
@@ -1,641 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: sergeymenshykh
|
||||
date: 2026-06-23
|
||||
deciders: sergeymenshykh
|
||||
---
|
||||
|
||||
# Skills Over MCP: Implementation Design Options
|
||||
|
||||
This document explores design options for two SEP-2640 features. The decisions are not yet finalized.
|
||||
|
||||
- **Part 1: MCP Resource Template Skills** - skills described by a URI template with variables that must be resolved before loading.
|
||||
- **Part 2: Direct Skill References** - reading `skill://` URIs referenced directly (e.g., in server instructions) without being listed in the index.
|
||||
|
||||
## Part 1: MCP Resource Template Skills
|
||||
|
||||
### Context and Problem Statement
|
||||
|
||||
The `AgentMcpSkillsSource` currently only supports `skill-md` type entries from `skill://index.json` (support for `archive` type is planned). The SEP-2640 specification also defines `mcp-resource-template` entries: **parameterized skill namespaces** described by a URI template with variables (e.g., `{product}`) that resolve to concrete `SKILL.md` URIs. Rather than materializing every skill in the index, the template's variables must be resolved before a skill can be loaded.
|
||||
|
||||
### Index Entry Format
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "git-workflow",
|
||||
"type": "skill-md",
|
||||
"description": "Follow this team's Git conventions for branching and commits",
|
||||
"url": "skill://git-workflow/SKILL.md"
|
||||
},
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key differences from `skill-md`:
|
||||
|
||||
| Field | `skill-md` | `mcp-resource-template` |
|
||||
|-------|------------|-------------------------|
|
||||
| `name` | Required (the skill name) | **Omitted** (represents many skills) |
|
||||
| `type` | `"skill-md"` | `"mcp-resource-template"` |
|
||||
| `url` | Concrete URI to `SKILL.md` | URI template with variables |
|
||||
| `description` | Describes the skill | Describes the addressable skill space |
|
||||
|
||||
### Use Cases
|
||||
|
||||
Template skills address two scenarios where listing concrete skills is impractical:
|
||||
|
||||
- **Large skill catalogs** - too many skills to enumerate every entry in the index.
|
||||
- **Dynamically generated skills** - skill content generated on the fly from parameters, so the set of valid skills is not known at index-creation time.
|
||||
|
||||
### How Template Skills Are Consumed
|
||||
|
||||
Per SEP-2640, the consumption flow relies on the MCP `completion/complete` method:
|
||||
|
||||
1. **Server registers a resource template** - The MCP server registers the same `url` value (e.g., `skill://docs/{product}/SKILL.md`) as an MCP [resource template](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates), wiring template variables to the [completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion).
|
||||
|
||||
2. **Host reads `skill://index.json`** - Discovers the template entry with `type: "mcp-resource-template"`.
|
||||
|
||||
3. **Host surfaces template in UI** - Presents the template as an interactive discovery point where the user fills in variables.
|
||||
|
||||
4. **Host calls `completion/complete`** - For each template variable (e.g., `{product}`), the host calls the MCP completion API to get possible values from the server:
|
||||
```json
|
||||
{
|
||||
"method": "completion/complete",
|
||||
"params": {
|
||||
"ref": {
|
||||
"type": "ref/resource",
|
||||
"uri": "skill://docs/{product}/SKILL.md"
|
||||
},
|
||||
"argument": {
|
||||
"name": "product",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
The server responds with possible completions:
|
||||
```json
|
||||
{
|
||||
"completion": {
|
||||
"values": ["widgets", "billing", "auth", "payments"],
|
||||
"hasMore": false,
|
||||
"total": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **User selects a value** - The user picks a value (e.g., `"billing"`) from the list.
|
||||
|
||||
6. **Host resolves the URI** - The template `skill://docs/{product}/SKILL.md` becomes the concrete URI `skill://docs/billing/SKILL.md`.
|
||||
|
||||
7. **Host reads the resolved skill** - Calls `resources/read` with the concrete URI and proceeds as with any `skill-md` skill.
|
||||
|
||||
### Potential Implementation Options
|
||||
|
||||
### Option 1: Callback on `AgentMcpSkillsSource` for Variable Value Selection
|
||||
|
||||
Add a callback to `AgentMcpSkillsSource` (or its options) that is invoked for each `mcp-resource-template` entry to let the caller select variable values.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. `AgentMcpSkillsSource.GetSkillsAsync()` reads `skill://index.json`
|
||||
2. For each entry with `type: "mcp-resource-template"`:
|
||||
- Parse the URI template to extract variable names (e.g., `{product}`)
|
||||
- Call the MCP `completion/complete` API to get possible values for each variable
|
||||
- Invoke the caller-provided callback with the variable name, description, and possible values
|
||||
- The callback returns a selected value and a `bool` indicating whether to include the skill
|
||||
3. Resolve the URI template with the selected values
|
||||
4. Create an `AgentMcpSkill` from the resolved URI and add it to the skills list
|
||||
|
||||
**API sketch:**
|
||||
|
||||
```csharp
|
||||
public delegate Task<(string? SelectedValue, bool IncludeSkill)> McpTemplateVariableSelector(
|
||||
string templateDescription,
|
||||
string variableName,
|
||||
IReadOnlyList<string> possibleValues,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Usage via builder:
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient, options => {
|
||||
options.TemplateVariableSelector = async (description, variable, values, ct) =>
|
||||
{
|
||||
// Present to user, return selection
|
||||
var selected = PromptUser(variable, values);
|
||||
return (selected, IncludeSkill: selected is not null);
|
||||
};
|
||||
})
|
||||
.Build();
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simple implementation
|
||||
- Easy to understand and use
|
||||
|
||||
**Cons:**
|
||||
- Cannot be used in server-side scenarios where there is no interactive user at skill-discovery time
|
||||
- Does not integrate with the agent's conversational flow
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Integrate into Agent Conversation via `ChatClientAgent` Decorator
|
||||
|
||||
Model the template variable resolution as a request/response interaction within the agent's conversational loop.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. A `DelegatingAIAgent` decorator (e.g., `McpTemplateSkillResolutionAgent`) intercepts `RunAsync`/`RunStreamingAsync` calls and checks whether the inner agent has an `AgentSkillsProvider` with an `AgentMcpSkillsSource` containing unresolved template entries. The check is performed via `GetService<AgentMcpSkillsSource>()` on the `AgentSkillsProvider`, which delegates to a `GetService` method on the `AgentSkillsSource` base class.
|
||||
|
||||
2. The decorator calls an internal member on `AgentMcpSkillsSource` to get the list of `mcp-resource-template` entries from the index. The `AgentMcpSkillsSource` needs to be extended with an internal member that exposes unresolved template entries separately from concrete skills.
|
||||
|
||||
3. For each template entry, the decorator calls an internal member on `AgentMcpSkillsSource` to retrieve possible values for the template's variables via the MCP `completion/complete` API.
|
||||
|
||||
4. For each variable needing resolution, the decorator returns an `McpResourceTemplateValueRequestContent` (inherits from MEAI's `InputRequestContent`) in the agent response - bypassing the call to the inner agent. The content carries the template description, variable name, and possible values.
|
||||
|
||||
5. The user app receives the response, identifies the `McpResourceTemplateValueRequestContent` content type, and displays UI to the user showing the variable name and possible values, or forwards it further downstream if the user app is a service.
|
||||
|
||||
6. The user selects a value, and the user app calls the agent again with a corresponding `McpResourceTemplateValueResponseContent` (inherits from MEAI's `InputResponseContent`) containing the selected value. The `RequestId` property (inherited from the base classes) correlates the response with the original request.
|
||||
|
||||
7. The decorator identifies the response content and provides the resolved values to `AgentMcpSkillsSource` so it can use them when constructing concrete skills.
|
||||
|
||||
8. Having resolved all template variables, the decorator calls `RunAsync`/`RunStreamingAsync` on the inner agent.
|
||||
|
||||
9. The inner agent invokes the `AgentSkillsProvider`, which calls `AgentMcpSkillsSource.GetSkillsAsync()`. The source now has all resolved variable values and constructs concrete `AgentMcpSkill` instances from the resolved URIs, so it can provide the skill content if requested by the model.
|
||||
|
||||
**API sketch:**
|
||||
|
||||
```csharp
|
||||
// New content types inheriting from MEAI's InputRequestContent/InputResponseContent:
|
||||
public sealed class McpResourceTemplateValueRequestContent : InputRequestContent
|
||||
{
|
||||
public string TemplateDescription { get; }
|
||||
public string VariableName { get; }
|
||||
public IReadOnlyList<string> PossibleValues { get; }
|
||||
public string TemplateUrl { get; }
|
||||
}
|
||||
|
||||
public sealed class McpResourceTemplateValueResponseContent : InputResponseContent
|
||||
{
|
||||
public string SelectedValue { get; }
|
||||
public string TemplateUrl { get; }
|
||||
}
|
||||
|
||||
// Decorator usage:
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
agent = new McpTemplateSkillResolutionAgent(agent);
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works in server-side scenarios
|
||||
- Fits the existing `DelegatingAIAgent` decorator pattern
|
||||
- Can be composed with other decorators (tool approval, etc.)
|
||||
|
||||
**Cons:**
|
||||
- Complex implementation
|
||||
- Requires user app awareness of the new content types
|
||||
- Users need to know that an additional decorator is required for handling MCP template skills, in addition to registering the MCP skills source
|
||||
- Resolved template variable values must be persisted across conversation turns so the decorator does not re-prompt on subsequent agent runs within the same session
|
||||
|
||||
**Note:** This writeup is high-level and may miss details that could change the design. A POC would be needed to validate the approach.
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. **Completion API limit** - The MCP completion API returns at most 100 values per request and provides no offset/cursor mechanism for enumeration. If a variable has more than 100 possible values, it's unclear how to retrieve the rest - the API only supports prefix-based filtering (typeahead), not bulk pagination.
|
||||
|
||||
2. **Multi-variable templates** - A template like `skill://{org}/{product}/SKILL.md` has multiple variables. Should they be resolved sequentially (org first, then product - since product values may depend on org) or presented together?
|
||||
|
||||
3. **Caching** - Should resolved template values be saved in the `AgentSession` so the user isn't re-prompted on every agent run? How should they be persisted between sessions?
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Direct Skill References
|
||||
|
||||
This part covers how to let the model read `skill://` URIs referenced directly (e.g., in an MCP server's `instructions`, in a resource, or in another skill's content) without being listed in `skill://index.json`.
|
||||
|
||||
### How MCP Skills and Relative Links Work Today
|
||||
|
||||
The `AgentMcpSkillsSource` discovers skills by reading the well-known `skill://index.json` resource from the MCP server:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "currency-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between world currencies using live rates.",
|
||||
"url": "skill://currency-converter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For each `skill-md` entry it creates an `AgentMcpSkill` instance - frontmatter (name/description) comes straight from the entry. The `AgentSkillsProvider` lists the discovered skills in the model's context (name + description):
|
||||
|
||||
```xml
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>unit-converter</name>
|
||||
<description>Convert between common units.</description>
|
||||
</skill>
|
||||
<skill>
|
||||
<name>currency-converter</name>
|
||||
<description>Convert between world currencies using live rates.</description>
|
||||
</skill>
|
||||
</available_skills>
|
||||
```
|
||||
|
||||
It also provides functions to the model so it can load a skill and access its resources:
|
||||
|
||||
```csharp
|
||||
// Loads the full content of a specific skill.
|
||||
load_skill(string skillName)
|
||||
|
||||
// Reads a resource associated with a skill (references, assets, dynamic data).
|
||||
read_skill_resource(string skillName, string resourceName)
|
||||
```
|
||||
|
||||
The model calls `load_skill("unit-converter")` and receives the skill content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units.
|
||||
---
|
||||
## Usage
|
||||
|
||||
For the full conversion table, see references/units-table.md.
|
||||
```
|
||||
|
||||
The skill body references `references/units-table.md` by relative path. The model calls `read_skill_resource("unit-converter", "references/units-table.md")` and receives the resource content:
|
||||
|
||||
```markdown
|
||||
# Unit Conversion Table
|
||||
|
||||
| From | To | Factor |
|
||||
| miles | km | 1.60934 |
|
||||
| kg | lbs | 2.20462 |
|
||||
```
|
||||
|
||||
### Direct Reference Examples
|
||||
|
||||
A `skill://` URI can appear in any of these locations:
|
||||
|
||||
**Server instructions** - the MCP server advertises a skill the model should load:
|
||||
|
||||
```text
|
||||
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
|
||||
```
|
||||
|
||||
**A skill body** - a skill's `SKILL.md` links to a sibling resource:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-standards
|
||||
description: Coding standards and conventions.
|
||||
---
|
||||
## Naming
|
||||
|
||||
Follow the naming rules in skill://code-standards/references/naming.md.
|
||||
```
|
||||
|
||||
**A resource** - the linked resource holds the actual content:
|
||||
|
||||
```markdown
|
||||
# Naming Rules
|
||||
|
||||
- Use PascalCase for public members and type names.
|
||||
- Use camelCase for locals and parameters.
|
||||
- Prefix interfaces with `I` (e.g. `ISkillReader`).
|
||||
- Suffix async methods with `Async`.
|
||||
|
||||
For examples, see skill://code-standards/references/naming-examples.md.
|
||||
```
|
||||
|
||||
How can the model access content by direct reference?
|
||||
|
||||
### Function for Reading Direct Skill References
|
||||
|
||||
### Option 1: Extend existing `load_skill` and `read_skill_resource` functions
|
||||
|
||||
```csharp
|
||||
// Added optional 'origin' and a direct skill:// URI is passed in 'skillName'.
|
||||
load_skill(string skillName, string? origin = null)
|
||||
|
||||
// Added optional 'origin', made 'skillName' optional, and a direct skill:// URI is passed in 'resourceName'.
|
||||
read_skill_resource(string resourceName, string? skillName = null, string? origin = null)
|
||||
```
|
||||
|
||||
The optional `origin` identifies the source/MCP server that should handle the direct URI.
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `load_skill("commit-guidelines")` |
|
||||
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
|
||||
| `skill://` link (skill) | `load_skill(skillName: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_skill_resource(resourceName: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- No new functions added: existing tool surface stays at two functions.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Unreliable on some models (gpt-4o, gpt-4.1-mini): it often omits `origin` when it should not or calls the wrong function.
|
||||
- Optional parameters create silent ambiguity - the model can pass `origin` for non-MCP skills or omit it for `skill://` URIs.
|
||||
|
||||
### Option 2 (Proposed): Add a dedicated `read_skill_uri` function alongside existing ones
|
||||
|
||||
```csharp
|
||||
// Existing functions stay unchanged.
|
||||
load_skill(string skillName)
|
||||
read_skill_resource(string skillName, string resourceName)
|
||||
|
||||
// New function added alongside: reads content by direct skill:// URI.
|
||||
read_skill_uri(string uri, string origin)
|
||||
```
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `load_skill("commit-guidelines")` |
|
||||
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
|
||||
| `skill://` link (skill) | `read_skill_uri(uri: "skill://commit-guidelines/SKILL.md", origin:"DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_skill_uri(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Purely additive - no changes to existing functions needed; `read_skill_uri` can be deferred and added later when direct `skill://` reference support is needed.
|
||||
- Granular approval: each function can have its own approval gate (like the existing `ScriptApproval` for `run_skill_script`), making per-operation approval for skill loading, resource reading, and direct URI access straightforward to add.
|
||||
- Both `uri` and `origin` are required - no silent misuse through optional parameters.
|
||||
- Clean split: `load_skill`/`read_skill_resource` for named skills, `read_skill_uri` for `skill://` links - no parameter ambiguity.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Three read functions (`load_skill`, `read_skill_resource`, `read_skill_uri`), not counting `run_skill_script`: larger tool surface than a single-function design.
|
||||
|
||||
### Option 3: Collapse `load_skill` and `read_skill_resource` into a single `read_resource` function
|
||||
|
||||
```csharp
|
||||
// Single entrypoint for all skill content. 'uri' is required; 'origin' is optional.
|
||||
read_resource(string uri, string? origin = null)
|
||||
```
|
||||
|
||||
- `uri` - what to read: a skill name, a relative resource path, or a `skill://` link.
|
||||
- `origin` - determines how `uri` is interpreted:
|
||||
- **omitted** → load skill by name (`uri` is the skill name).
|
||||
- **skill name** → read a relative resource (`uri` is the path within that skill).
|
||||
- **server name** → read content by the `skill://` link (`uri` is handled by the source identified by the `[Origin: X]` marker).
|
||||
|
||||
Dispatch is ordered: null `origin` routes to Case 1; if `origin` names a known skill, routes to Case 2; otherwise tries to find an `ISkillUriReader` whose `CanRead` returns true for `origin` (Case 3).
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `read_resource(uri: "commit-guidelines")` |
|
||||
| Relative resource | `read_resource(uri: "examples/COMMIT_EXAMPLES.md", origin: "commit-guidelines")` |
|
||||
| `skill://` link (skill) | `read_resource(uri: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_resource(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Minimal tool surface: one read function instead of two or three (not counting `run_skill_script`) reduces token usage and gives the model fewer choices.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- No per-operation approval: all cases (skill loading, resource reading, direct URI access) share one function, so approval cannot be scoped to individual operations.
|
||||
- Unreliable on gpt-4.1-mini: omits `origin` when reading `skill://` links, passes skill name as `origin` when loading a plain skill (should be omitted), and hallucinates resource names (e.g. `API_SPECIFICATION.md`) that do not exist.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Origin Marker
|
||||
|
||||
A `skill://` URI does not carry an origin, but the model needs to provide one when reading it. The `origin` is what routes the read call to the source that can handle the URI - the provider uses it to pick the matching source. Since the URI itself carries no such hint, the MCP source injects an `[Origin: ...]` marker wherever a `skill://` URI appears, so the model can read it back and pass it as the `origin` argument.
|
||||
|
||||
The marker is only added when the content actually contains `skill://` references. If a piece of content (server instructions, a skill body, or a resource) has no `skill://` URIs, there is nothing for the model to read back, so no marker is injected.
|
||||
|
||||
Into **server instructions**, which may mention `skill://` URIs directly:
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
|
||||
```
|
||||
|
||||
Into **skill bodies**, since a `SKILL.md` may reference other `skill://` URIs (a resource file or a related skill):
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
# Code Standards
|
||||
|
||||
For naming conventions, load skill://code-standards/references/naming.md.
|
||||
```
|
||||
|
||||
Into **skill resources**, since a resource may itself reference further `skill://` URIs:
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
# Naming Rules
|
||||
|
||||
- Use PascalCase for public members and type names.
|
||||
- Use camelCase for locals and parameters.
|
||||
|
||||
For examples, see skill://code-standards/references/naming-examples.md.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Read-by-URI Capability: Interface vs Base Class Virtual Methods
|
||||
|
||||
Now let's look at how an `AgentSkillsSource` can opt in to reading `skill://` URIs and signal that capability to the provider.
|
||||
|
||||
### Option 1: New `ISkillUriReader` interface
|
||||
|
||||
```csharp
|
||||
public interface ISkillUriReader
|
||||
{
|
||||
// Returns true if this reader can handle the given skill:// URI from the given origin.
|
||||
bool CanRead(string uri, string origin);
|
||||
|
||||
// Reads and returns the content for the given skill:// URI.
|
||||
Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Sources that support direct `skill://` URI reads - such as `AgentMcpSkillsSource` - implement this interface to opt in.
|
||||
|
||||
The provider discovers readers via a service locator and dispatches to the first that can handle the URI:
|
||||
|
||||
```csharp
|
||||
// Discover all registered readers.
|
||||
var readers = source.GetService<IEnumerable<ISkillUriReader>>();
|
||||
|
||||
// Pick the first reader that can handle the URI.
|
||||
var reader = readers.FirstOrDefault(r => r.CanRead(uri, origin))
|
||||
?? throw new InvalidOperationException($"No reader can handle URI '{uri}' from origin '{origin}'.");
|
||||
|
||||
// Delegate the read to it.
|
||||
return await reader.ReadByUriAsync(uri, origin, cancellationToken);
|
||||
```
|
||||
|
||||
The provider may treat a source implementing `ISkillUriReader` as the signal to advertise `read_skill_uri`: if at least one registered source implements the interface, the function is exposed to the model; otherwise it is not.
|
||||
|
||||
### Option 2 (Proposed): Virtual methods on `AgentSkillsSource` base class
|
||||
|
||||
```csharp
|
||||
public abstract class AgentSkillsSource
|
||||
{
|
||||
// New members for reading by URI.
|
||||
|
||||
// Whether this source can read by URI; drives whether read_skill_uri is advertised. Off by default.
|
||||
public virtual bool SupportsReadByUri => false;
|
||||
|
||||
// Returns true if this source can handle the given skill:// URI from the given origin.
|
||||
public virtual bool CanReadByUri(string uri, string origin) => false;
|
||||
|
||||
// Reads and returns the content for the given skill:// URI.
|
||||
public virtual Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
// Existing member.
|
||||
public abstract Task<IList<AgentSkills>> GetSkillsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Sources opt in by overriding, and the provider calls them directly:
|
||||
|
||||
```csharp
|
||||
// AgentMcpSkillsSource opts in by overriding the virtuals.
|
||||
public override bool SupportsReadByUri => true;
|
||||
|
||||
// Handles the URI when its origin matches this source's MCP server.
|
||||
public override bool CanReadByUri(string uri, string origin)
|
||||
=> string.Equals(origin, this.Origin, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Reads content by skill:// URI from the MCP server.
|
||||
public override Task<string?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken)
|
||||
=> /* resolve uri via the MCP server identified by origin */;
|
||||
```
|
||||
|
||||
All sources inherit the methods, so there is no type signal - `SupportsReadByUri` fills that role. The function is advertised when any registered source returns `true`.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Aspect | Option 1: Interface | Option 2: Base class virtual methods |
|
||||
|--------|---------------------|--------------------------------------|
|
||||
| Discovery | Service locator | Direct call on source |
|
||||
| Advertising signal | Interface implementation | `SupportsReadByUri` flag |
|
||||
| Adding new members | Breaking change | Non-breaking |
|
||||
| Complexity | Higher | Lower |
|
||||
|
||||
---
|
||||
|
||||
### Include MCP Server Instructions Into Agent Instructions
|
||||
|
||||
MCP server instructions may contain the `skill://` references the model needs, so we want to surface them in the agent's instructions. But they can also carry system prompts or behavioral directives irrelevant to the agent, polluting context - so inclusion is **opt-in** via the `IncludeServerInstructions` option:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentMcpSkillsSourceOptions
|
||||
{
|
||||
// When true, the MCP server's instructions are injected into the agent instructions. Off by default.
|
||||
public bool IncludeServerInstructions { get; set; }
|
||||
}
|
||||
|
||||
builder.UseMcpSkills(mcpClient, options => options.IncludeServerInstructions = true);
|
||||
```
|
||||
|
||||
When enabled, the instructions travel alongside the discovered skills on `AgentSkillsResult`:
|
||||
|
||||
```csharp
|
||||
public class AgentSkillsResult
|
||||
{
|
||||
// The skills discovered from the source.
|
||||
public IList<AgentSkill> Skills { get; }
|
||||
|
||||
// The MCP server instructions, when IncludeServerInstructions is enabled; otherwise null.
|
||||
public string? Instructions { get; }
|
||||
}
|
||||
```
|
||||
|
||||
The `AgentSkillsProvider` then appends them to its own skill-usage guidance when building the agent's instructions:
|
||||
|
||||
```csharp
|
||||
var result = await source.GetSkillsAsync(cancellationToken);
|
||||
|
||||
var instructions = DefaultSkillsInstructionPrompt;
|
||||
if (!string.IsNullOrWhiteSpace(result.Instructions))
|
||||
{
|
||||
// Combine the provider's skill-usage guidance with the server instructions.
|
||||
instructions += Environment.NewLine + result.Instructions;
|
||||
}
|
||||
```
|
||||
|
||||
### Enabling Direct Skill References
|
||||
|
||||
Following direct `skill://` references is **disabled by default** and activated via an option. When enabled, the provider advertises the read function to the model, and the source injects the `[Origin: ...]` marker into all content provided by the MCP server that contains `skill://` references. When disabled, no function is advertised and no marker is injected.
|
||||
|
||||
```csharp
|
||||
public sealed class AgentMcpSkillsSourceOptions
|
||||
{
|
||||
public bool EnableDirectReferences { get; set; }
|
||||
}
|
||||
|
||||
builder.UseMcpSkills(mcpClient, options => options.EnableDirectReferences = true);
|
||||
```
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### Template Variable Resolution: Callback vs Decorator (Part 1)
|
||||
|
||||
**Postponed.** Deferring this decision until:
|
||||
|
||||
- We have a concrete list of scenarios that require template variable resolution.
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
### Function for Reading Direct Skill References (Part 2)
|
||||
|
||||
**Postponed.** Leaning toward **Option 2 - dedicated `read_skill_uri` function alongside existing ones** (purely additive, and each function can have its own approval gate for granular per-operation approval), but deferring the decision until:
|
||||
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
### Read-by-URI Capability: Interface vs Base Class (Part 2)
|
||||
|
||||
**Postponed.** Leaning toward **Option 2 - virtual methods on `AgentSkillsSource`** (non-breaking, lower complexity, and a natural fit with the existing base class hierarchy), but deferring the decision until:
|
||||
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
The method naming (`SupportsReadByUri`, `CanReadByUri`, `ReadByUriAsync`) should also be abstracted a little more before adoption, so the same members can be reused when a similar direct-reference concept is needed for other skill types (e.g. file skills).
|
||||
|
||||
## References
|
||||
|
||||
- [SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) - Draft proposal
|
||||
- [SEP-2640 Implementation Guidelines: Model-Driven Resource Loading](https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md#hosts-model-driven-resource-loading)
|
||||
- [MCP Completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - Used for template variable resolution
|
||||
- [MCP Resource Templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates)
|
||||
- [Skills Over MCP Working Group](https://github.com/modelcontextprotocol/experimental-ext-skills)
|
||||
- [Open Question #4: Multi-server skill dependencies](https://github.com/modelcontextprotocol/experimental-ext-skills/issues/39)
|
||||
- [Anthropic Agent Skills - Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) - Prior art: single skill entrypoint + generic file reads
|
||||
- [Anthropic Agent Skills in the SDK](https://code.claude.com/docs/en/agent-sdk/skills) - The `Skill` tool exposed to the model
|
||||
@@ -1,356 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-19
|
||||
deciders: eavanvalkenburg, moonbox3, TaoChenOSU, chetantoshnival
|
||||
consulted: westey-m
|
||||
informed:
|
||||
---
|
||||
|
||||
# Python identity lifetimes for sessions, tasks, and continuation
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Python `AgentSession` currently carries a local `session_id`, an optional opaque service continuation
|
||||
`service_session_id`, and provider state. `service_session_id` is any service-owned value that lets that service continue
|
||||
a conversation, session, or thread; chat clients happen to map it through the abstract `conversation_id` ChatOption, but
|
||||
other agent types can use it differently. It is not a generic correlation field, and generic correlation should not
|
||||
require parsing or understanding that opaque service-owned value.
|
||||
|
||||
The related issues mix values with different lifetimes:
|
||||
|
||||
- **Session / conversation identity**: values that group a multi-turn interaction. Examples: A2A `context_id`, OpenAI
|
||||
Responses `conversation` (`conv_*`) or response-chain continuation (`previous_response_id`).
|
||||
- **Task identity**: values that identify a protocol task and may affect future protocol calls. Example: A2A `task_id`.
|
||||
- **Message / response identity**: values that identify an output message or response. Examples: A2A `message_id` /
|
||||
`artifact_id`, OpenAI Responses response id (`resp_*`).
|
||||
- **Continuation token**: a framework resume payload for in-progress work. It may contain the same underlying value as a
|
||||
protocol id, such as A2A `task_id`, but it only exists when there is an unfinished operation to resume.
|
||||
|
||||
These values should not automatically live in the same object just because they all help "continue" something. A value
|
||||
belongs in `AgentSession` only when it is needed to continue future calls across turns. A value that identifies one
|
||||
result belongs on the response or message. A value that resumes in-progress work belongs in a `ContinuationToken`.
|
||||
|
||||
An `AgentSession` created for one agent is not expected to be guaranteed to work against another agent. When a session is
|
||||
used with an incompatible agent, protocol, or service, the framework should still help users understand what is wrong as
|
||||
early as possible, preferably before calling out to the remote service.
|
||||
|
||||
For #4673, native conversation identity propagation should be based on `AgentSession` where the value is durable session
|
||||
state. For #4893, A2A `context_id` and `task_id` need a coherent Agent Framework mapping.
|
||||
|
||||
AG-UI is out of scope for the decision. Its `thread_id` already maps to `AgentSession.session_id` in the normal wrapper
|
||||
path, and `run_id` is wrapper-owned event correlation. If AG-UI run correlation needs framework telemetry integration
|
||||
later, that should be handled as a run-context/telemetry design, not as session identity.
|
||||
|
||||
### Concrete gap example
|
||||
|
||||
At the protocol level, the durable continuation payload shapes are different:
|
||||
|
||||
```json
|
||||
// A2A: future calls may need multiple durable protocol fields
|
||||
{
|
||||
"context_id": "ctx_123",
|
||||
"task_id": "task_789",
|
||||
"task_state": "input_required"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// OpenAI Responses: future calls usually need one continuation value
|
||||
{
|
||||
"previous_response_id": "resp_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
The gap is that A2A continuation state is multi-field while OpenAI continuation is
|
||||
typically single-field.
|
||||
|
||||
## Current implementation notes
|
||||
|
||||
- A2A currently has `A2AAgentSession`, but `A2AAgent.create_session(...)` does not automatically return it.
|
||||
- A2A currently mirrors `context_id` into `service_session_id`; that is current behavior, not necessarily the target
|
||||
abstraction.
|
||||
- A2A `task_id` is not just cosmetic correlation. It is used for `task_id` when a task is `INPUT_REQUIRED`, for
|
||||
`reference_task_ids` when refining a previous task, and inside `A2AContinuationToken` for in-progress tasks.
|
||||
- `RawAgent._prepare_run_context(...)` currently forwards `active_session.service_session_id` as chat `conversation_id`,
|
||||
so any non-string or formatted value affects existing chat-client paths.
|
||||
- `OpenAIChatClient` maps chat options `conversation_id` to the Responses API as `previous_response_id` for `resp_*`,
|
||||
`conversation` for `conv_*`, and defaults unrecognized strings to `previous_response_id`. When `store` is not `False`,
|
||||
it returns `response.conversation.id` when available, otherwise `response.id`, as the next service continuation value.
|
||||
- For Responses API, the response id (`resp_*`) is also the response/message identity surfaced as
|
||||
`ChatResponse.response_id`; when used for continuation on the next request, it becomes the `previous_response_id`
|
||||
value.
|
||||
- Python A2A has not been released as stable yet, so its session factory or session shape can still be adjusted before
|
||||
release.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Preserve `AgentSession.session_id` as the local/client conversation identity.
|
||||
- Preserve `AgentSession.service_session_id` as an opaque service-owned continuation handle.
|
||||
- Keep `AgentSession` for durable state needed across turns, not per-run bookkeeping.
|
||||
- Store values needed by future calls in durable session state; keep values that only resume in-progress work in
|
||||
`ContinuationToken`.
|
||||
- Fix the current confusion where session, task, response, and continuation values can be treated as interchangeable
|
||||
because they all participate in "continuing" something.
|
||||
- Make the implementation following this ADR preserve the lifetime split clearly: future-call state, in-progress resume
|
||||
tokens, response/message ids, and protocol event correlation must not be silently mixed.
|
||||
- Expose durable continuation state in a typed way when future calls depend on it.
|
||||
- Let telemetry correlate runs without parsing opaque service continuation handles.
|
||||
- Reuse existing run/context surfaces before introducing a new identity abstraction.
|
||||
- Keep MCP and other remote tool boundaries safe: framework identity must not be forwarded to remote tools unless an
|
||||
existing explicit opt-in mechanism says so.
|
||||
- Keep existing `AgentSession.to_dict()` / `from_dict()` migration and compatibility straightforward.
|
||||
- Stay close to .NET where there is already behavior to match, especially A2A's `ContextId`, `TaskId`, and `TaskState`.
|
||||
- Detect incompatible session identity shapes as early as practical, preferably before a remote service call.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not design a provider-agnostic conversation creation API here. That is tracked separately in #6622.
|
||||
- Do not make `service_session_id` a generic telemetry or run-correlation field.
|
||||
- Do not introduce a new identity object if existing run/context objects can carry the selected per-run correlation value.
|
||||
- Do not make a session from one agent guaranteed to work against another agent.
|
||||
- Do not optimize the public `agent.run(...)` API for protocol-wrapper internals.
|
||||
|
||||
## Remaining question: durable shape for additional continuation state
|
||||
|
||||
- Option A: Use protocol-specific `AgentSession` subclasses.
|
||||
- Option B: Extend `service_session_id` with richer service-owned values.
|
||||
- Option C: Add a dedicated dict for additional session details.
|
||||
- Option D: Store additional durable state inside `AgentSession.state`.
|
||||
|
||||
### Option A: Use protocol-specific `AgentSession` subclasses
|
||||
|
||||
Each protocol or agent type that needs additional durable state keeps a specialized `AgentSession` subclass. For A2A,
|
||||
that means keeping `A2AAgentSession` for A2A-specific durable state and changing `A2AAgent.create_session(...)` to return
|
||||
that type.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# First call returns a task that future A2A messages may need to reference.
|
||||
session = await a2a_agent.create_session()
|
||||
|
||||
response = await a2a_agent.run(
|
||||
message,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# A2AAgent updates durable A2A protocol state from the returned task/status payload.
|
||||
# The user does not set these manually.
|
||||
assert isinstance(session, A2AAgentSession)
|
||||
assert session.task_id is not None
|
||||
assert session.task_state is not None
|
||||
|
||||
# Later call reuses the durable A2A session state. A2AAgent decides whether to send task_id
|
||||
# for INPUT_REQUIRED or reference_task_ids for task refinement.
|
||||
next_response = await a2a_agent.run(
|
||||
next_message,
|
||||
session=session,
|
||||
)
|
||||
```
|
||||
|
||||
- Good, because protocol-specific state stays in a protocol-specific type.
|
||||
- Good, because it aligns with .NET A2A's `A2AAgentSession` shape.
|
||||
- Good, because Python A2A can still make this pre-release session factory adjustment.
|
||||
- Good, because `task_state` does not get promoted to a base `AgentSession` concept.
|
||||
- Bad, because generic consumers cannot read protocol-specific state without knowing about the subclass or a helper API.
|
||||
- Bad, because it depends on each subclass consistently setting shared session fields such as `service_session_id` where
|
||||
those are part of the shared abstraction.
|
||||
|
||||
### Option B: Extend `service_session_id` with richer service-owned values
|
||||
|
||||
Keep the common `service_session_id` case as a plain string. When an agent/service needs more than one service-owned
|
||||
continuation value, allow `service_session_id` to be a typed structured value, such as a `TypedDict`. The main session ID
|
||||
used for `gen_ai.conversation.id` should still be extracted by the owning agent, not inferred by generic telemetry code.
|
||||
|
||||
Examples:
|
||||
|
||||
```python
|
||||
simple_session = AgentSession(
|
||||
service_session_id="resp_123",
|
||||
)
|
||||
|
||||
structured_session = AgentSession(
|
||||
service_session_id=A2AServiceSessionId(
|
||||
context_id="ctx_123",
|
||||
task_id="task_789",
|
||||
task_state=TaskState.TASK_STATE_WORKING,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- Good, because the common case remains a plain string and stays simple.
|
||||
- Good, because richer service-owned continuation state stays under the existing continuation property.
|
||||
- Good, because a structured value can make framework-side validation possible before a value is sent back to a service.
|
||||
- Good, because A2A can keep `context_id`, `task_id`, and `task_state` together as the service/protocol-owned continuation
|
||||
value without adding A2A fields to base `AgentSession`.
|
||||
- Neutral, because telemetry needs an agent-owned extractor to pick the `gen_ai.conversation.id` value from either a
|
||||
string or structured `service_session_id`.
|
||||
- Neutral, because Python A2A would need a pre-release adjustment to stop relying on `A2AAgentSession` for these fields.
|
||||
- Bad, because changing the `service_session_id` type is a compatibility risk for users, providers, serialization, and
|
||||
tests.
|
||||
- Bad, because every path that sends `service_session_id` back to a service must consistently extract/adapt the
|
||||
service-owned continuation component.
|
||||
|
||||
### Option C: Add a dedicated dict for additional session details
|
||||
|
||||
Keep `service_session_id` as the primary opaque service-owned continuation handle, and add a separate dictionary for
|
||||
additional durable protocol/service values that need to travel with the session.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
session = AgentSession(
|
||||
service_session_id="ctx_123",
|
||||
session_details={
|
||||
"task_id": "task_456",
|
||||
"task_state": TaskState.TASK_STATE_WORKING,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- Good, because the main service continuation handle stays a plain `service_session_id` string.
|
||||
- Good, because extra state has an explicit home and does not overload `service_session_id`.
|
||||
- Good, because generic consumers can look in one documented place for additional session-scoped values.
|
||||
- Neutral, because helper APIs can hide the raw dictionary access.
|
||||
- Bad, because this still introduces string-keyed state unless the dict values are wrapped by typed helpers.
|
||||
- Bad, because it adds another public session field that needs serialization, naming, and compatibility rules.
|
||||
- Bad, because generic consumers still need to understand the shape or use helpers for the selected agent/session type.
|
||||
|
||||
### Option D: Store additional durable state inside `AgentSession.state`
|
||||
|
||||
Keep base `AgentSession` unchanged and store additional durable continuation/protocol state under namespaced keys in
|
||||
`session.state`.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
session = AgentSession(session_id="ctx_123")
|
||||
|
||||
session.state["a2a"] = {
|
||||
"task_id": "task_456",
|
||||
"task_state": TaskState.TASK_STATE_WORKING,
|
||||
}
|
||||
```
|
||||
|
||||
- Good, because it avoids new public fields and avoids a subclass requirement.
|
||||
- Good, because `AgentSession.state` already exists for provider/session state.
|
||||
- Neutral, because helper APIs can hide the raw dictionary access.
|
||||
- Bad, because stringly typed state is easier to corrupt and harder to validate.
|
||||
- Bad, because generic consumers need helper APIs anyway; directly reading nested dictionaries is not a good abstraction.
|
||||
- Bad, because users may accidentally overwrite or persist invalid protocol state.
|
||||
|
||||
## Decision
|
||||
|
||||
Chosen decision criteria for the future: **split identity by lifecycle**.
|
||||
|
||||
When a protocol emits an id/token, place it by answering "what lifecycle does this value serve?":
|
||||
|
||||
- **Future-call continuation state** -> durable session state. Examples: A2A `context_id` + `task_id` + `task_state`;
|
||||
OpenAI Responses `previous_response_id`/`conversation`.
|
||||
- **Single-result identity** -> response/message object only. Examples: OpenAI `resp_*`, A2A `message_id`,
|
||||
A2A `artifact_id`.
|
||||
- **Resume unfinished work** -> `ContinuationToken` only. Example: a token carrying in-progress task resume data.
|
||||
- **Run-start-only request fields** -> run method arguments/options, not durable session state. Example: A2A
|
||||
`reference_task_ids` for a specific follow-up/refinement request.
|
||||
- **Per-run correlation/telemetry** -> protocol wrapper or run context, not `AgentSession`. Example: wrapper-managed
|
||||
`run_id` used only for tracing/events.
|
||||
|
||||
Durable-state option decision: **Option B: Extend `service_session_id` with richer service-owned values**.
|
||||
This does **not** add a new top-level identity abstraction; it keeps continuation identity under
|
||||
`service_session_id` and keeps run correlation in existing run/telemetry context.
|
||||
The immediate implementation gap is mainly in A2A mapping clarity, but the lifecycle split applies
|
||||
consistently across providers.
|
||||
|
||||
To support telemetry, `BaseAgent` should expose a method that accepts an `AgentSession | None` and returns the value to
|
||||
use for `gen_ai.conversation.id`. The default implementation should return `session.service_session_id` when it is a
|
||||
string. Agents that use a structured `service_session_id`, such as `A2AAgent`, should override that method and return the
|
||||
appropriate primary session/context value.
|
||||
|
||||
## Appendix: A2A `task_id` and `reference_task_ids` implementation check
|
||||
|
||||
The A2A protocol distinguishes a message's `task_id` from `reference_task_ids`:
|
||||
|
||||
- `task_id` associates the message with a specific task.
|
||||
- `reference_task_ids` provides additional task context, for example when a new task refines or follows up on the result
|
||||
of a previous task.
|
||||
|
||||
The protocol does not appear to prescribe that `task_id` and `reference_task_ids` are mutually exclusive. If both are
|
||||
present, the natural reading is that the message is associated with one task while also referencing other tasks for
|
||||
context. The serving agent decides how to interpret that context.
|
||||
|
||||
The Python implementation should check and likely adjust the current behavior:
|
||||
|
||||
- `task_id` should be updated by the current run when the remote A2A service returns a task/status payload.
|
||||
- `task_id` should remain durable A2A session state when needed for future calls, for example when a task is
|
||||
`INPUT_REQUIRED`.
|
||||
- `reference_task_ids` should be a run parameter / caller intent for the current request, not implicit durable session
|
||||
continuation state.
|
||||
- A follow-up/refinement request should pass explicit `reference_task_ids` when it wants to reference previous tasks.
|
||||
- If both session `task_id` and run `reference_task_ids` are present, the wrapper should preserve the protocol
|
||||
distinction rather than treating one as a replacement for the other.
|
||||
- If no `reference_task_ids` are supplied, the wrapper should not automatically infer them from the last session task
|
||||
unless we deliberately keep that convenience for compatibility.
|
||||
|
||||
## Appendix: implementation notes for Option B
|
||||
|
||||
The exact names are implementation details, but the shape should be:
|
||||
|
||||
```python
|
||||
class A2AServiceSessionId(TypedDict):
|
||||
context_id: str
|
||||
task_id: str | None
|
||||
task_state: TaskState | None
|
||||
|
||||
|
||||
class AgentSession:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
service_session_id: str | ServiceSessionId | None = None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
|
||||
class BaseAgent:
|
||||
def _get_otel_conversation_id(self, session: AgentSession | None) -> str | None:
|
||||
service_session_id = session.service_session_id if session else None
|
||||
return service_session_id if isinstance(service_session_id, str) else None
|
||||
|
||||
|
||||
class A2AAgent(BaseAgent):
|
||||
def _get_otel_conversation_id(self, session: AgentSession | None) -> str | None:
|
||||
service_session_id = session.service_session_id if session else None
|
||||
if isinstance(service_session_id, Mapping):
|
||||
return service_session_id.get("context_id")
|
||||
return service_session_id if isinstance(service_session_id, str) else None
|
||||
|
||||
|
||||
class AgentTelemetryLayer:
|
||||
def _trace_agent_invocation(...):
|
||||
attributes = _get_span_attributes(
|
||||
...,
|
||||
thread_id=self._get_otel_conversation_id(session),
|
||||
...,
|
||||
)
|
||||
```
|
||||
|
||||
This keeps the OpenTelemetry extraction decision with the agent that owns the service continuation shape. Generic OTel
|
||||
code should not parse structured `service_session_id` values directly.
|
||||
|
||||
`AgentSession` must also be updated so `service_session_id` can store either the current string value or a structured
|
||||
service-owned value. Serialization must preserve both shapes, and existing serialized sessions with string
|
||||
`service_session_id` must continue to round-trip unchanged.
|
||||
|
||||
## More Information
|
||||
|
||||
Related work and issues:
|
||||
|
||||
- #4673: native conversation ID propagation.
|
||||
- #4893: align A2A protocol concepts with Agent Framework session/continuation concepts.
|
||||
- #2931: Foundry-specific conversation creation helper, split into a separate Python PR.
|
||||
- #6622: broader provider-agnostic conversation creation API discussion requiring .NET sync.
|
||||
- [ADR-0015](0015-agent-run-context.md): AgentRunContext for Agent Run.
|
||||
- [ADR-0018](0018-agentthread-serialization.md): AgentSession serialization.
|
||||
- [ADR-0026](0026-hosted-session-identity-context.md): hosted session identity context.
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-06-29
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Hosted platform context (user id + call id) for Foundry Hosting on AgentServer 2.0
|
||||
|
||||
Supersedes [ADR-0026](0026-hosted-session-identity-context.md).
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0026](0026-hosted-session-identity-context.md) sourced the hosted-agent end-user identity from `ResponseContext.Isolation` (an `IsolationContext` typed `UserIsolationKey` / `ChatIsolationKey`), injected by the platform as the `x-agent-user-isolation-key` and `x-agent-chat-isolation-key` headers.
|
||||
|
||||
`Azure.AI.AgentServer.*` 2.0.0 (responses protocol `2.0.0`) removes that surface. `ResponseContext.Isolation` is gone; the platform now exposes `ResponseContext.PlatformContext` (a `PlatformContext` typed `UserIdKey` and `CallId`), populated from the `x-agent-user-id` and `x-agent-foundry-call-id` headers. The chat isolation key no longer exists, and a new per-request **call id** is introduced that first-party Foundry services (the toolbox proxy in particular) require on outbound calls to resolve the server-side-stored caller context. The hosting layer in `Microsoft.Agents.AI.Foundry.Hosting` had to migrate to this contract without changing the public shape that samples and providers depend on.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Track the breaking `Azure.AI.AgentServer.*` 2.0.0 surface (`PlatformContext` replacing `Isolation`) while keeping the same per-user partitioning guarantees from ADR-0026.
|
||||
- Keep the change **internal**: existing hosted samples and `AIContextProvider`s must not need code changes. `session.GetHostedContext().UserId`, `HostedSessionIsolationKeyProvider`, and `AddFoundryResponses` stay source-compatible.
|
||||
- Forward the new per-request call id verbatim on outbound calls to Foundry first-party services so per-user toolbox OAuth consent and other server-side caller-context lookups keep working.
|
||||
- Remain resilient on protocol `1.0.0`: when only the legacy headers are present, `UserIdKey` still resolves and `CallId` is simply absent.
|
||||
- Preserve the strict-resume tamper defense from ADR-0026 with identity now reduced to user only.
|
||||
|
||||
## Considered Options
|
||||
|
||||
For the identity source:
|
||||
|
||||
1. **Map `ResponseContext.PlatformContext.UserIdKey`** into the existing `HostedSessionContext` (user only), keeping ADR-0026's storage shape and read accessor.
|
||||
2. Keep a `ChatId` slot on `HostedSessionContext` for backward source-compatibility, populated from `CallId` or left null.
|
||||
|
||||
For the call id propagation:
|
||||
|
||||
A. **A request-scoped ambient (`HostedCallContext`, an `AsyncLocal<string?>`)** set by the handler and re-applied before each egress point, read by the outbound delegating handler.
|
||||
B. Thread the call id through every method signature down to the toolbox bearer handler.
|
||||
|
||||
For session keying (previously implied by the conversation/chat pairing):
|
||||
|
||||
I. **`HostedConversationKey`** resolving a stable partition from `conversation_id ?? partition(previous_response_id) ?? partition(responseId)`.
|
||||
II. Continue keying on the container session id (`FOUNDRY_AGENT_SESSION_ID`).
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen: **Option 1** for identity, **Option A** for call id, **Option I** for session keying.
|
||||
|
||||
Rationale:
|
||||
|
||||
- **`ChatId` dropped (Option 2 rejected).** The platform no longer supplies a chat key; carrying a synthetic one would invent identity the trust boundary does not provide. `HostedSessionContext` becomes user-only (`HostedSessionContext(string userId)` / `UserId`), and the strict-resume check validates `UserId` alone. The corresponding `HostedFoundryMemoryProviderScopes` values `PerChat` and `PerUserAndChat` are removed; `PerUser` is retained.
|
||||
- **Ambient call id (Option B rejected).** Writing `HostedCallContext.CallId` inside the streaming `async IAsyncEnumerable` iterator is reverted across each `yield`, so a single up-front assignment is lost before the toolbox/MCP egress runs. The handler therefore captures `context.PlatformContext?.CallId` once and **re-applies it immediately before each egress point**; `FoundryToolboxBearerTokenHandler` forwards it as `x-agent-foundry-call-id`. The ambient is request-scoped and never leaks into the caller's execution context (guarded by a unit test).
|
||||
- **`HostedConversationKey` (Option II rejected).** One container serves many conversations for its lifetime, so the container session id cannot key per-conversation state. The partition key is derived from the conversation/`previous_response_id`/minted response id instead.
|
||||
|
||||
Implementation summary in `Microsoft.Agents.AI.Foundry.Hosting`:
|
||||
|
||||
| Type | Visibility | Change vs ADR-0026 |
|
||||
|---|---|---|
|
||||
| `HostedSessionContext` | public sealed | Now user-only (`UserId`); `ChatId` removed. |
|
||||
| `PlatformHostedSessionIsolationKeyProvider` | internal sealed | Maps `context.PlatformContext.UserIdKey` (was `context.Isolation.UserIsolationKey` / `ChatIsolationKey`). |
|
||||
| `HostedCallContext` | internal static | New. Request-scoped `AsyncLocal<string?>` holding the `x-agent-foundry-call-id` value. |
|
||||
| `HostedConversationKey` | internal | New. Resolves the per-conversation partition key. |
|
||||
| `FoundryToolboxBearerTokenHandler` | internal | Now also forwards `x-agent-foundry-call-id` outbound. |
|
||||
| `HostedFoundryMemoryProviderScopes` | public | `PerChat` / `PerUserAndChat` removed; `PerUser` kept. |
|
||||
|
||||
Package manifests bump the responses container protocol to `2.0.0` (invocations stays `1.0.0`).
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Per-user memory partitioning and the strict-resume tamper defense from ADR-0026 are preserved with no public API churn for samples or providers.
|
||||
- Per-user toolbox OAuth consent and other server-side caller-context lookups keep working because the per-request call id is forwarded on egress.
|
||||
- Works unchanged on protocol `1.0.0` (no call id) and `2.0.0`.
|
||||
|
||||
Negative:
|
||||
|
||||
- `HostedSessionContext.ChatId` and the `PerChat` / `PerUserAndChat` memory scopes are removed; any out-of-tree consumer that referenced them must move to user-scoped partitioning.
|
||||
- The call id must be re-applied before every egress point because of the async-iterator `AsyncLocal` revert; a missed re-apply silently drops the header. This is covered by unit tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- HMAC tamper signatures over the persisted context remain unimplemented; equality comparison against `ResponseContext.PlatformContext` on every request is sufficient because the platform sets the header at the trust boundary.
|
||||
- The per-request `User` field on `CreateResponse` is still intentionally not consumed.
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-06-30
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Per-agent and per-user session-storage isolation for Foundry Hosting
|
||||
|
||||
Builds on [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md).
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
A Foundry hosted container can serve many end users (and, in .NET, many agents) over its lifetime. The
|
||||
`AgentSessionStore` persists each turn's `AgentSession` (which for a workflow agent carries the workflow
|
||||
checkpoint, and which also carries the tool-approval mapping via `ToolApprovalIdMap` in the session state
|
||||
bag). [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md) protected cross-user access only through
|
||||
the strict-resume identity check (a 403 when the persisted `HostedSessionContext.UserId` does not match the
|
||||
live request). The persisted artifacts themselves were keyed by `conversationId` (+ agent name), not
|
||||
physically partitioned per user, so a forged `conversation_id` would still resolve to another user's file
|
||||
path before the identity check rejected it.
|
||||
|
||||
The Python hosting package added physical per-user partitioning (`<root>/<user_id>/<context_id>`) plus a
|
||||
reject-style path-traversal guard. We want .NET to provide the same defense-in-depth, adapted to the .NET
|
||||
hosting model.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Defense in depth: a forged/guessed id must not even resolve to another tenant's storage path, independent
|
||||
of the identity check.
|
||||
- Multi-agent hosting: a single .NET container hosts multiple agents resolved from keyed DI, so the layout
|
||||
must isolate per agent as well as per user (Python hosts a single agent and needs no agent layer).
|
||||
- Path-traversal safety (CWE-22) for the untrusted, platform-injected user id.
|
||||
- Back-compat for local development (no `x-agent-user-id` header) and for direct/non-hosted store use.
|
||||
- Keep the change contained and avoid the async-iterator `AsyncLocal` revert hazard from ADR-0030.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Path partition inside `FileSystemAgentSessionStore`**, threading the user id explicitly through the
|
||||
`AgentSessionStore` API, with self-describing prefixed segments.
|
||||
- A delegating store that prefixes the conversation id with the user id (the
|
||||
`IsolationKeyScopedAgentSessionStore` pattern from `Microsoft.Agents.AI.Hosting`). Rejected: still needs the
|
||||
user id on the read path and yields a flat key rather than nested per-tenant directories.
|
||||
- An `AsyncLocal<string?>` user-context set by the handler. Rejected: the session is saved in the handler's
|
||||
`finally` after the streaming `yield`s, where an `AsyncLocal` set up front is reverted (the same hazard
|
||||
that forced explicit call-id re-application in ADR-0030). Explicit threading is safer and clearer.
|
||||
- A separate per-user approval store (as in Python). Rejected as unnecessary: see below.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Path layout with self-describing, prefixed segments; user id threaded explicitly:
|
||||
|
||||
{root}/ a-{agentName} / u-{userId} / c-{contextId}.json
|
||||
|
||||
- `a-` (agent), `u-` (user), `c-` (context) are constant literals applied to the sanitized/validated value,
|
||||
so a collapsed layout is never ambiguous and a user id can never masquerade as an agent name.
|
||||
- `contextId` is `HostedConversationKey.Resolve` (conversation_id, else the partition of
|
||||
previous_response_id, else of the minted response id).
|
||||
- The agent and context layers are always present (Foundry always deploys a named agent). The only
|
||||
collapse is the `u-` layer: present when a user id is resolved (Foundry header, or local dev fallback),
|
||||
absent for raw local runs with no header (`{root}/a-{agent}/c-{conv}.json`). There is no user-only or
|
||||
no-agent layout.
|
||||
|
||||
Other elements:
|
||||
|
||||
- `string? userId` was added as a **required** parameter (no default) on `AgentSessionStore.GetSessionAsync` /
|
||||
`SaveSessionAsync` (a contained, breaking change to the experimental Foundry abstraction; both in-tree
|
||||
implementations and the two handler call sites were updated). It is required rather than optional so a
|
||||
caller can never silently persist a session unscoped; a genuine no-user caller (local without the header,
|
||||
or a non-hosted direct caller) passes `null` explicitly. `AgentFrameworkResponseHandler` resolves the user
|
||||
id before loading the session.
|
||||
- Path-traversal guard: the user id is rejected (not sanitized) when it is not a single safe path segment
|
||||
(path separators, NUL, drive letters, rooted paths, all-dot segments). After building the path, the
|
||||
fully-resolved path is asserted to remain under the storage root.
|
||||
- The strict-resume 403 identity check from ADR-0030 is **kept** as the second defense layer (it still
|
||||
catches a session that reaches the wrong partition, e.g. via a non-partitioning custom store or in-process
|
||||
tampering).
|
||||
- **No separate approval store.** The tool-approval mapping lives in `ToolApprovalIdMap` ->
|
||||
`AgentSessionStateBag`, which is serialized into the session checkpoint, so partitioning the session path
|
||||
isolates pending approvals per tenant automatically. (Python needs a separate per-user approval store only
|
||||
because it models approvals as a standalone store.)
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Cross-tenant isolation is now defense-in-depth: physical per-agent/per-user partitioning plus the identity
|
||||
check. Approvals and workflow checkpoints inherit the partitioning because they ride in the session.
|
||||
- Self-describing prefixes make the on-disk layout auditable and collision-free across collapse cases.
|
||||
|
||||
Negative:
|
||||
|
||||
- Breaking change to the experimental Foundry `AgentSessionStore` API (added `userId`).
|
||||
- The on-disk layout and leaf filename change (`<conv>.json` -> `c-<conv>.json`), orphaning sessions written
|
||||
by the ADR-0030 release. Acceptable for an experimental package; a fresh session is created on next use.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Encryption at rest and quota enforcement remain platform concerns.
|
||||
- Non-Foundry hosting layers can adopt an equivalent scheme independently.
|
||||
|
||||
## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed
|
||||
|
||||
Superseding the ADR-0026/0030 behavior where a `null` result from `HostedSessionIsolationKeyProvider`
|
||||
always became a 500, `AgentFrameworkResponseHandler` now branches on `FoundryEnvironment.IsHosted`:
|
||||
|
||||
- **Hosted** (`IsHosted == true`, production): a `null` identity is still a hard error (500). Isolation
|
||||
stays strict; the platform always injects `x-agent-user-id`.
|
||||
- **Not hosted** (local `docker run` / `dotnet run`): a `null` identity is tolerated. Per-user isolation
|
||||
is simply not triggered — the handler passes `userId == null` to the store (the documented "no user
|
||||
partition", `{root}/a-{agent}/c-{conv}.json`), stamps no `HostedSessionContext`, and runs no
|
||||
strict-resume check. Contributors can run a hosted image locally with zero extra setup.
|
||||
|
||||
Consequently the sample-side `DevTemporaryLocalUserIdProvider` and `AddDevTemporaryLocalContributorSetup`
|
||||
were removed. To simulate distinct users locally, send an `x-agent-user-id` request header; the default
|
||||
`PlatformHostedSessionIsolationKeyProvider` reads it via `ResponseContext.PlatformContext.UserIdKey`
|
||||
(the SDK's `PlatformContext.FromRequest` populates it from the header unconditionally, hosted or not).
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-07-08
|
||||
deciders: rogerbarreto
|
||||
consulted: eavanvalkenburg
|
||||
informed: []
|
||||
---
|
||||
|
||||
# .NET hosting: OpenAI Responses protocol helpers for app-owned routing
|
||||
|
||||
Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET.
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel
|
||||
framework toward **protocol conversion helpers plus optional execution state**: Agent Framework owns
|
||||
protocol-native <-> run conversion, while the application owns HTTP routing, authentication,
|
||||
middleware, storage, and native SDK calls.
|
||||
|
||||
.NET already ships `Microsoft.Agents.AI.Hosting.OpenAI`, a route-owning server that **exposes an
|
||||
`AIAgent` (or workflow) as the OpenAI Responses API** (`MapOpenAIResponses` + `IResponsesService`). It
|
||||
owns the routes, an in-memory response/conversation store, streaming, and lifecycle. The question is
|
||||
what, if anything, .NET must add to satisfy the ADR-0027 boundary.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Do not reinvent conversion logic that already exists and is battle-tested in `Hosting.OpenAI`.
|
||||
- Give applications a way to own their own route/auth/middleware/storage while reusing Agent Framework
|
||||
conversion (the ADR-0027 boundary).
|
||||
- Keep the released public surface small.
|
||||
- Stay consistent with the existing .NET hosting stack, which deliberately does **not** use the OpenAI
|
||||
SDK Responses types server-side (it hand-rolled its own wire model).
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Self-contained new package that reimplements conversion using the OpenAI SDK Responses types
|
||||
(mirrors the Python `agent-framework-hosting-responses` lineage).
|
||||
2. New package that reuses `Hosting.OpenAI`'s internal converters (via `InternalsVisibleTo` or by
|
||||
moving the conversion core out).
|
||||
3. Thin public helper facade **inside** `Hosting.OpenAI` over the existing internal converters, plus
|
||||
protocol-neutral execution-state holders in `Microsoft.Agents.AI.Hosting`.
|
||||
|
||||
### First-principles gap analysis
|
||||
|
||||
A capability comparison of the ADR-0027 / PR #6891 helper surface against the existing .NET stack:
|
||||
|
||||
| Python helper capability | .NET today | Status |
|
||||
| --- | --- | --- |
|
||||
| `responses_to_run` | `ResponseInput.GetInputMessages` + `InputMessage.ToChatMessage` + `OpenAIResponsesMapOptions.RunOptionsFactory` | exists, internal |
|
||||
| `responses_from_run` | `AgentResponseExtensions.ToResponse` | exists, internal |
|
||||
| `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer |
|
||||
| `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone |
|
||||
| `create_response_id` | `IdGenerator` | exists, internal |
|
||||
| `AgentState` (target + store, get-or-create, callable/awaitable target) | `AgentSessionStore` (get-or-create + save + serialize + isolation) + DI container (target lifetime + async setup) | create-on-miss lives in the store; per-run instance and deferred/async target come from DI, so no separate holder is needed |
|
||||
| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added |
|
||||
| `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor |
|
||||
| App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** |
|
||||
|
||||
.NET already covers ~90% of the capability, and more richly (its streaming renderer even emits workflow
|
||||
events; its session store serializes and supports per-principal isolation, neither of which Python's
|
||||
in-memory `SessionStore` does). The single genuine gap is the **ownership model**: every conversion
|
||||
primitive is bundled behind the route-owning server, so an application cannot own its own route and
|
||||
call just the conversion.
|
||||
|
||||
Note on lineage: Python's Responses offering was introduced *as a channel* (PR #6580) and always used
|
||||
the `openai` SDK Responses types. .NET's `Hosting.OpenAI` predates and is independent of channels and
|
||||
hand-rolled its own server-side wire DTOs (the SDK's Responses types are client-shaped and awkward
|
||||
server-side). So Option 1 would both reinvent a working asset and contradict the .NET codebase's own
|
||||
precedent.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **3. Thin public helper facade inside `Hosting.OpenAI` plus neutral state holders**,
|
||||
because the only real gap is the ownership model, so the work is to *un-bundle* the existing
|
||||
converters, not to rebuild them or add a package.
|
||||
|
||||
### Public surface
|
||||
|
||||
`Microsoft.Agents.AI.Hosting.OpenAI` gains a single public static facade, `OpenAIResponses`, whose
|
||||
boundary is `System.Text.Json` (`JsonElement`/streamed events), matching Python's dict boundary and
|
||||
keeping the hand-rolled wire DTOs internal:
|
||||
|
||||
- `OpenAIResponses.ToAgentRunRequest(JsonElement body)` -> messages + `AgentRunOptions?`.
|
||||
- `OpenAIResponses.WriteResponse(AgentRunResponse response, string responseId, string? sessionId = null)`
|
||||
-> a Responses-shaped `JsonElement`.
|
||||
- `OpenAIResponses.WriteResponseStreamAsync(IAsyncEnumerable<AgentRunResponseUpdate> updates, string responseId, ...)`
|
||||
-> Responses SSE `data:` frames.
|
||||
- `OpenAIResponses.GetSessionId(JsonElement body)` -> `previous_response_id` or `conversation` id, or
|
||||
`null`. Kept **separate** from `ToAgentRunRequest` so the trust boundary is visible: choosing to use
|
||||
a request-derived key is an explicit application decision.
|
||||
- `OpenAIResponses.CreateResponseId()` -> a `resp_*` id.
|
||||
|
||||
All helpers are side-effect-free and delegate to the existing internal converters. `MapOpenAIResponses`
|
||||
public behavior is unchanged; it and the facade share one internal conversion core (an internal
|
||||
`ToResponse` overload with an optional originating request is added so the facade can render without a
|
||||
request object).
|
||||
|
||||
### Optional execution state (neutral package)
|
||||
|
||||
`Microsoft.Agents.AI.Hosting` gains:
|
||||
|
||||
- `AgentSessionStore.DeleteSessionAsync(...)` (+ `InMemoryAgentSessionStore` implementation and
|
||||
isolation-decorator passthrough): the one missing store operation.
|
||||
- No agent-side holder. Applications use `AgentSessionStore` directly: `GetSessionAsync(agent, id)`
|
||||
already creates on miss and returns an independent session instance per call (so concurrent calls fork
|
||||
the same stored state rather than sharing an instance), `SaveSessionAsync(agent, id, session)` persists
|
||||
post-run (including under a newly minted id), and `DeleteSessionAsync(agent, id)` removes it. An earlier
|
||||
draft added a `HostedAgentState` holder, but once create-on-miss lives in the store and the store does no
|
||||
cross-call locking, the holder would only bind the `agent` argument, which is not enough to justify a
|
||||
public type. Any coordination for concurrent runs against the same id is the application's concern.
|
||||
(Unlike Python, whose `SessionStore` is get/set-only and whose `AgentState` therefore owns
|
||||
create-on-miss, .NET's store already owns it.)
|
||||
|
||||
Python's `AgentState` carries two further responsibilities beyond create-on-miss: it accepts a callable
|
||||
or awaitable target so the host can (1) obtain a fresh agent instance per run and (2) defer expensive or
|
||||
asynchronous agent setup while keeping server construction synchronous. In .NET these two concerns are
|
||||
owned by the dependency-injection container, not by a hosting type. Per-run lifetime is expressed by the
|
||||
registration lifetime (`AddScoped`/`AddTransient` yields a fresh `AIAgent` per request or scope, resolved
|
||||
by the framework), and deferred or asynchronous construction is expressed by an async factory registration
|
||||
(for example an `async` factory delegate, `ActivatorUtilities`, or resolving the agent inside the request
|
||||
after any async warm-up), so the route handler resolves an already-built agent from the container. An
|
||||
`AIAgent` is also safe to invoke concurrently (per-turn state lives in `AgentSession`, not the agent), so
|
||||
the "fresh instance per run" motivation does not apply to it the way it does to a workflow. This is the
|
||||
deliberate asymmetry with `HostedWorkflowState` below: a `Workflow` instance is a stateful run engine that
|
||||
cannot be driven by two runners at once, so the factory/`cacheWorkflow` affordance is load-bearing there
|
||||
for correctness, whereas for agents the container already provides both per-run instances and async setup.
|
||||
- `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an
|
||||
internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint
|
||||
store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has
|
||||
no ordering, so the holder remembers the head checkpoint per session to resume. On subsequent turns it
|
||||
restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python
|
||||
host's restore-then-run semantics), rather than continuing a halted run with no input. When the
|
||||
in-memory cursor misses (new holder / process restart) it reads the session's latest checkpoint from the
|
||||
`CheckpointManager`, so a durable manager resumes across restarts. It accepts either a single workflow
|
||||
instance (which cannot be run by two runners at once, so its turns are processed one at a time) or a
|
||||
workflow factory (`Func<CancellationToken, ValueTask<Workflow>>`). By default the factory builds a fresh
|
||||
instance per run so independent sessions run in parallel; with `cacheWorkflow: true` the factory is invoked
|
||||
once lazily and its result is cached and reused (a deferred, cached target that, like the instance, cannot
|
||||
run concurrent turns). A resume rehydrates an instance from the session's checkpoint in the shared store, so
|
||||
per-run instances still continue the same run; concurrent turns against the same session id remain the
|
||||
application's coordination responsibility.
|
||||
|
||||
### Scope
|
||||
|
||||
Responses only for v1; the facade is named so a parallel `OpenAIChatCompletions` facade can follow.
|
||||
No new package, no OpenAI-SDK-typed reimplementation, no change to `MapOpenAIResponses` public
|
||||
behavior.
|
||||
|
||||
### Security responsibilities
|
||||
|
||||
Consistent with ADR-0027, the application owns the trust boundary. `GetSessionId(...)` returns an
|
||||
untrusted candidate key; the application must authenticate the caller and authorize/bind the id before
|
||||
using it as an `AgentSessionStore` key or workflow checkpoint session id. Multi-user hosts must scope
|
||||
the session store per principal (`IsolationKeyScopedAgentSessionStore`). Helpers stay side-effect-free;
|
||||
persistence happens only after the run completes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Smallest possible surface: the released addition is one facade type plus one thin workflow state
|
||||
holder and one new store method (agents use `AgentSessionStore` directly, no holder).
|
||||
- No duplicated conversion; the app-owned-routing path and the route-owning server share one core.
|
||||
- `MapOpenAIResponses` users are unaffected.
|
||||
|
||||
Negative:
|
||||
|
||||
- The facade's `JsonElement` boundary is less strongly typed than the internal DTOs (accepted to keep
|
||||
the wire model internal and mirror Python's dict boundary).
|
||||
- Workflow resume relies on an in-memory head cursor by default; durable multi-replica hosts must
|
||||
supply their own cursor persistence.
|
||||
|
||||
## More Information
|
||||
|
||||
- Parent ADR: [ADR-0027](0027-hosting-channels.md).
|
||||
- Spec: `docs/specs/003-dotnet-hosting-protocol-helpers.md`.
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
status: Accepted
|
||||
contact: cgillum
|
||||
date: 2026-07-21
|
||||
deciders: cgillum, vrdmr, chetantoshniwal
|
||||
consulted: westey-m, eavanvalkenburg, kshyju, larohra, ahmedmuhsin
|
||||
informed:
|
||||
---
|
||||
|
||||
# Extract Durable Task and Azure Functions hosting into a separate repository
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The Durable Task and Azure Functions hosting integrations (`agent-framework-durabletask`,
|
||||
`agent-framework-azurefunctions`, plus their samples, docs, and CI) currently live in the
|
||||
`microsoft/agent-framework` (MAF) monorepo. They carry heavyweight specialized dependencies
|
||||
(Azure Functions runtime, Durable Task) and need integration-test infrastructure (Functions Core
|
||||
Tools, Azurite, a DTS emulator) that the core repo otherwise does not.
|
||||
|
||||
This ADR proposes moving them into a dedicated repository
|
||||
([`microsoft/agent-framework-durable-extension`](https://github.com/microsoft/agent-framework-durable-extension))
|
||||
and considers how to do so without breaking existing users who import them today.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Independent lifecycle** — the hosting integrations should be able to version and release on their
|
||||
own cadence, decoupled from core (extends [ADR-0008](0008-python-subpackages.md)'s goal of keeping
|
||||
heavyweight/optional dependencies out of the main package).
|
||||
- **Dependency & CI isolation** — keep core lean and its PR pipeline free of heavyweight hosting
|
||||
dependencies and integration-test prerequisites.
|
||||
- **Ownership** — a dedicated repo would give the integrations their own issues, CODEOWNERS, and
|
||||
contribution flow.
|
||||
- **No breaking change** — existing `from agent_framework.azure import …` code and
|
||||
`pip install agent-framework[all]` should keep working (stable-import-path guarantee, ADR-0008).
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep in the MAF repo** (status quo).
|
||||
2. **Move out, drop the core shim** — the extension becomes standalone; core stops re-exporting the
|
||||
types and removes them from `[all]`.
|
||||
3. **Move out, keep core's backward-compat shim + `[all]`** (proposed) — the code would live in the
|
||||
new repo; core would still lazily re-export the entry-point types from `agent_framework.azure` and
|
||||
keep both packages in the `[all]` extra (resolved from PyPI).
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Proposed choice: **Option 3.** Extract the integrations for lifecycle, dependency, and ownership
|
||||
isolation, while preserving the existing import surface so the move is invisible to consumers.
|
||||
Option 1 forgoes the isolation benefits; Option 2 achieves them but would be a breaking change for
|
||||
existing imports and the `[all]` extra.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good — would give independent release cadence, a leaner/faster core repo and CI, and clear
|
||||
ownership for the hosting integrations.
|
||||
- Good — no user-visible break: existing imports and `agent-framework[all]` would continue to work
|
||||
unchanged.
|
||||
- Neutral — type *definitions* would live once in the extension; the core shim would re-export only a
|
||||
curated subset of entry-point types (no metadata duplication). The extension's own samples/docs
|
||||
would import directly from `agent_framework_durabletask` / `agent_framework_azurefunctions`; the
|
||||
shim would be compatibility-only.
|
||||
- Neutral — users may still open GitHub issues against the core repo for problems in the extension,
|
||||
but the extension's own repo would be the primary place for issues and PRs. These issues would
|
||||
need to be triaged and transferred to the extension repo.
|
||||
- Neutral — **.NET public API boundary.** The extension should prefer the smallest stable public core
|
||||
API over friend-assembly access where the capability is useful to external hosts or tooling. For
|
||||
workflow routing metadata, the agreed first step is to expose a read-only `Workflow.Edges` view plus
|
||||
public `EdgeData.Connection` and `FanOutEdgeData`, while keeping graph construction internal
|
||||
([#7448](https://github.com/microsoft/agent-framework/issues/7448),
|
||||
[#7459](https://github.com/microsoft/agent-framework/pull/7459)). This reduces internal coupling but
|
||||
adds a public API compatibility commitment. Any remaining internal dependencies would still need to
|
||||
be evaluated individually before retaining `InternalsVisibleTo`.
|
||||
- Bad — **Python version coordination.** Core's shim correctness would track the extension's publish
|
||||
cadence. In the other direction, when an extension package adopts a new core API, maintainers would
|
||||
need to choose per feature between raising its minimum core version (simpler, but forces every
|
||||
extension user to upgrade) and conditional imports with fallback behavior (preserves support for
|
||||
older core versions, but adds implementation and testing complexity).
|
||||
|
||||
## Validation
|
||||
|
||||
Compliance would be validated by:
|
||||
|
||||
- Python: `uv lock --check` passing with both packages resolving from PyPI; the shim entry-point
|
||||
symbols importing at runtime after `uv sync --all-extras`; `pyright` staying clean on
|
||||
`agent_framework/azure/__init__.pyi`; and extension tests running against both the minimum supported
|
||||
and current core versions when conditional compatibility behavior is used.
|
||||
- .NET: tests from an external assembly confirming that workflow routing metadata is inspectable
|
||||
through the agreed public surface while graph construction remains internal.
|
||||
|
||||
A known risk is **publish-lag**: if a symbol is added to core's shim before the extension has
|
||||
published a release that exports it, that symbol would not resolve at runtime. The mitigation would
|
||||
be to omit any such symbol from the shim until the extension publishes it, then add the entry and
|
||||
re-lock.
|
||||
|
||||
## More Information
|
||||
|
||||
- Related: [ADR-0008](0008-python-subpackages.md) (vendor namespaces + stable import paths),
|
||||
[ADR-0021](0021-provider-leading-clients.md) (lazy-loading gateways),
|
||||
[issue #7448](https://github.com/microsoft/agent-framework/issues/7448) and
|
||||
[PR #7459](https://github.com/microsoft/agent-framework/pull/7459) (.NET workflow routing API).
|
||||
- Follow-ups: during extraction, keep the shim's re-exported symbols in sync with each newly
|
||||
published extension release (adding any symbol only once the extension publishes it); document the
|
||||
direct-import convention in the extension's samples READMEs so samples are not switched back to the
|
||||
shim.
|
||||
@@ -1,642 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-07-22
|
||||
deciders: eavanvalkenburg, chetantoshniwal
|
||||
consulted: TaoChenOSU, moonbox3, peibekwe, rogerbarreto, westey-m
|
||||
informed:
|
||||
---
|
||||
|
||||
# Feature-usage bitmask in the User-Agent
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
We can see which Agent Framework packages are installed and that *some* framework
|
||||
call happened (via the existing `agent-framework-python/{version}` User-Agent),
|
||||
but we have no usage-based signal about **which features are actually exercised**
|
||||
at runtime, nor which are used *together* (e.g. workflows + MCP + Foundry). How
|
||||
can we collect a lightweight, privacy-respecting signal of feature usage for the
|
||||
traffic we can actually read, without standing up new event pipelines?
|
||||
|
||||
The detailed mechanism is in [SPEC-004](../specs/004-feature-usage-telemetry.md);
|
||||
the per-language bit tables are in
|
||||
[feature-usage-bit-registry.md](../specs/feature-usage-bit-registry.md).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Transparency** — openly documented, human-decodable, user-controllable. No
|
||||
hidden or obfuscated telemetry.
|
||||
- **First-party scope / no third-party leakage** — emission requires both an
|
||||
explicitly approved client/pipeline family and an approved actual HTTPS origin
|
||||
on every request (including redirects). Credentials or an Azure setting alone
|
||||
never approve a custom gateway/origin.
|
||||
- **Live signal** — read the process's observed-feature set *so far* at request
|
||||
send time, rather than freezing it at client construction.
|
||||
- **Low cost / few moving parts** — reuse telemetry already in the request path;
|
||||
bounded fixed-width processing; as little machinery as the job needs.
|
||||
- **Privacy** — encode only coarse "observed at least once" Boolean feature
|
||||
state, never counts; no identifiers, arguments, prompts, payloads,
|
||||
model/deployment names, endpoints, or customer-defined names.
|
||||
- **Use, not presence** — package-level indexes mean a capability reached its
|
||||
first meaningful activation, not that a package was installed/imported or a
|
||||
DI container constructed an unused service.
|
||||
- **Versioning discipline** — v1 is a point-in-time decision. Adding bits later is
|
||||
easier than removing or redefining them, so the initial table should lean toward
|
||||
fewer bits and avoid forcing v2 shortly after launch.
|
||||
- **Allocation discipline** — each bit represents a stable framework-owned
|
||||
capability with a concrete product/support question and an actual-use mark
|
||||
point; implementation detail and speculative distinctions stay out.
|
||||
|
||||
## Considered Options
|
||||
|
||||
The options below are grouped by the decisions that matter: the **transport**,
|
||||
the **granularity**, and the **registry sharing model**.
|
||||
|
||||
### Transport
|
||||
|
||||
#### A. User-Agent token, first-party only, per request (chosen)
|
||||
|
||||
Stamp a `(feat=...)` comment onto the UA, but only on approved Azure/Foundry
|
||||
client pipelines, and re-evaluate it per request.
|
||||
|
||||
- Good, reuses telemetry already sent to approved backends we can read.
|
||||
- Good, request-time stamping reflects the live mask (not frozen at construction).
|
||||
- Good, first-party scoping means no fingerprint leaks to third-party providers.
|
||||
- Good, two-factor destination approval (pipeline + actual origin) denies custom
|
||||
`base_url` gateways and strips the token on unapproved redirect hops.
|
||||
- Good, maps onto .NET's existing per-request UA pipeline policies unchanged.
|
||||
- Neutral, v1 stamps only pipelines the framework creates or can configure
|
||||
through supported public hooks. It does not mutate caller-owned clients or
|
||||
reach into private SDK pipelines.
|
||||
- Bad, no signal for traffic that never hits a first-party endpoint (accepted —
|
||||
we couldn't read it anyway).
|
||||
|
||||
#### B. User-Agent token on all clients
|
||||
|
||||
- Good, simplest to wire (one static header).
|
||||
- Bad, sends a deployment fingerprint to OpenAI/Anthropic/AWS/Google logs we
|
||||
cannot read — privacy leak for zero benefit.
|
||||
- Bad, baked into static `default_headers`, so it freezes at client construction
|
||||
and reports a near-empty mask.
|
||||
|
||||
#### C. OpenTelemetry span/resource attribute
|
||||
|
||||
- Good, precise per-call usage; no UA change.
|
||||
- Bad (**privacy — the main reason to hold it**), a span attribute broadcasts the
|
||||
feature-combination fingerprint into the user's **general** telemetry pipeline,
|
||||
which is typically exported to third-party APM vendors (Datadog, Honeycomb, …).
|
||||
That re-introduces exactly the fingerprint leakage the first-party-only UA
|
||||
scoping (A) was chosen to avoid — just into a different set of third parties.
|
||||
- Bad (secondary), also a cardinality footgun (a growing, combinatorial value
|
||||
must never become a metric dimension).
|
||||
- Neutral, for the team's own goal it reaches us only if the user exports to
|
||||
Azure Monitor and we query it.
|
||||
- **Deferred, not rejected.** The version prefix lets us add it later **if** the
|
||||
User-Agent path cannot answer a concrete query and there is an acceptable
|
||||
scoped/redacted variant.
|
||||
|
||||
#### D. Bespoke usage events
|
||||
|
||||
- Good, richest detail and flexibility.
|
||||
- Bad, new data flow and cost; larger privacy surface; heavy to build and review;
|
||||
overkill for a coarse "which features" signal.
|
||||
|
||||
#### E. Install/import-time signal only (status quo-ish)
|
||||
|
||||
- Good, zero new runtime work.
|
||||
- Bad, measures installation, not usage; cannot capture feature combinations —
|
||||
does not solve the problem.
|
||||
|
||||
### Accumulation scope
|
||||
|
||||
#### S1. Process-global, monotonic mask (chosen)
|
||||
|
||||
A single mask per process; bits are OR-ed in as features are first used and never
|
||||
cleared. The token reflects "what this process has used so far."
|
||||
|
||||
- **Binary interpretation:** a set bit means the feature was observed at least
|
||||
once in this process before the request was sent. A bit repeated on later
|
||||
requests is the same Boolean observation, not another feature use. It cannot be
|
||||
summed into invocation, request, agent, user, or tenant counts.
|
||||
- Good, fits our **mixed feature lifecycle**: many features are *not* bound to an
|
||||
outbound service request — an agent/workflow may first run or build, a
|
||||
context/history provider may first participate in a session, and a host may
|
||||
start serving before the request that later emits the token. A process-wide
|
||||
mask can carry those activations forward.
|
||||
- Good, trivial and cheap: one OR under a lock (Python) / one atomic OR into one
|
||||
of two 64-bit lanes (.NET); no per-request state plumbing.
|
||||
- Good, deliberately coarse for privacy: it avoids emitting a sequence of exact
|
||||
per-call feature combinations that could reconstruct a workload's behavioral
|
||||
trace.
|
||||
- Neutral, coarser than per-call — early requests carry fewer bits than later
|
||||
ones, and the token says "this process used X", not "this call used X" or "X
|
||||
was used this many times."
|
||||
|
||||
For example, at time 1 Agent A can use MCP and a Foundry chat client. At time 2,
|
||||
Agent B in the same worker can make a normal Foundry chat call without MCP. The
|
||||
time-2 request still carries the MCP bit because MCP was previously observed in
|
||||
that process. It does **not** say Agent B used MCP, nor count a second MCP use.
|
||||
|
||||
#### S2. Per-request set, reset between calls (botocore's model — rejected)
|
||||
|
||||
AWS botocore scopes its `m/` feature codes to a `contextvars` set that is reset
|
||||
between requests, giving exact per-call attribution (and it deliberately no-ops
|
||||
when called outside a request context to avoid features bleeding across requests).
|
||||
See [Prior art](#prior-art).
|
||||
|
||||
- Good, exact per-call attribution directly in the User-Agent.
|
||||
- Bad, **assumes every feature is exercised inside a single service request** —
|
||||
true for botocore (an SDK natively bound to AWS service calls), but *not* for
|
||||
us. Our features split into request-scoped ones (a chat call, an MCP tool
|
||||
invocation) and decidedly non-request ones (workflow build/start, provider
|
||||
participation, hosting startup). The latter have no service request to attach to, so a
|
||||
per-request set would simply miss them.
|
||||
- Bad, needs `contextvars` propagation through every async/threaded path and a
|
||||
reset discipline, plus enable/disable calls around every scoped operation; the
|
||||
bleed-guard botocore documents is the warning sign.
|
||||
- Bad, creates a more detailed per-call behavioral trace, increasing the privacy
|
||||
sensitivity and review burden compared with a coarse process-lifetime Boolean.
|
||||
- Note, per-call attribution for the request-scoped subset is better served by
|
||||
the deferred OTel span path (option C) than by reshaping the UA token.
|
||||
|
||||
### Granularity
|
||||
|
||||
The mechanism can support several granularities. The remaining decision before
|
||||
implementation is how detailed v1 should be. The estimates below are
|
||||
intentionally rough; v1 uses a fixed 128-bit bound to leave useful headroom
|
||||
without making the registry unbounded.
|
||||
|
||||
#### F0. Package-level bits
|
||||
|
||||
One bit per package, set on first use of a package-owned public API, client,
|
||||
provider, or tool. It is **not** set on install, import, or assembly load.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `agent-framework-core` when `Agent`, `AgentSession`, `Workflow`, etc. is used.
|
||||
- `agent-framework-tools` when a `LocalShellTool` or `DockerShellTool` first
|
||||
executes/probes its shell capability.
|
||||
- `agent-framework-foundry` when a `FoundryChatClient`, `FoundryAgent`, etc.
|
||||
performs its first Foundry operation.
|
||||
- `agent-framework-openai` when `OpenAIChatClient`,
|
||||
`OpenAIEmbeddingClient`, etc. performs its first provider operation.
|
||||
- `agent-framework-azure-ai-search` when `AzureAISearchContextProvider` is used.
|
||||
- `agent-framework-azure-cosmos` when `CosmosHistoryProvider` is used.
|
||||
- `agent-framework-redis` when `RedisContextProvider` or `RedisHistoryProvider`
|
||||
is used.
|
||||
|
||||
Examples that do **not** get separate bits: merely installed dependencies;
|
||||
imports or DI construction with no activation; `Agent` vs `AgentSession` vs
|
||||
`InMemoryHistoryProvider`; `FunctionTool` vs `MCPStdioTool` vs `LocalShellTool`
|
||||
vs `DockerShellTool`; `FoundryChatClient` vs `FoundryAgent`; `OpenAIChatClient`
|
||||
vs `OpenAIEmbeddingClient`.
|
||||
|
||||
Rough estimate: Python ~25-35 bits; .NET ~15-25 bits.
|
||||
|
||||
- Good, lowest specificity and simplest registry.
|
||||
- Good, clearly measures usage rather than dependency inventory if bits are set
|
||||
only at package-owned public API/client/provider/tool use sites.
|
||||
- Bad, does not answer which major capability within a package is used.
|
||||
|
||||
#### F1. Package + major capability bits
|
||||
|
||||
Package bits plus selected major capabilities that are product-distinct and stable
|
||||
across implementations.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `agent-framework-core` plus `Agent`.
|
||||
- `AgentSession` plus `InMemoryHistoryProvider` / `FileHistoryProvider` as one
|
||||
history capability.
|
||||
- `Workflow` / `FunctionalWorkflow` as one workflow capability.
|
||||
- `FunctionTool`; MCP transports as one MCP capability; shell tools as one shell
|
||||
capability.
|
||||
- Skills provider plus stable source types: file, in-memory/programmatic, and
|
||||
MCP-backed skills (with .NET inline/class skill distinctions).
|
||||
- Foundry chat/agent/embedding capabilities; OpenAI chat/embedding capabilities.
|
||||
|
||||
Examples that do **not** get separate bits: `InMemoryHistoryProvider` vs
|
||||
`FileHistoryProvider`; `WorkflowBuilder`, `AgentExecutor`, `FunctionExecutor`, or
|
||||
`FanOutEdgeGroup`; `MCPStdioTool` vs `MCPStreamableHTTPTool` vs
|
||||
`MCPWebsocketTool`; `LocalShellTool` vs `DockerShellTool` vs
|
||||
`ShellEnvironmentProvider` vs `ShellPolicy`; `OpenAIChatClient` vs
|
||||
`OpenAIChatCompletionClient`; skill-source decorators such as caching, filtering,
|
||||
deduplication, and aggregation.
|
||||
|
||||
Rough estimate: Python ~60-70 indexes; .NET ~45-55 indexes. The current candidate
|
||||
registry is at 63 Python / 52 .NET assigned indexes.
|
||||
|
||||
- Good, likely answers the first product adoption questions while staying compact.
|
||||
- Good, fits comfortably within 128 bits while leaving room for additive package
|
||||
and feature growth.
|
||||
- Neutral, some provider internals remain collapsed until a later additive bit is
|
||||
justified.
|
||||
|
||||
#### F2. Public construct / concrete type bits
|
||||
|
||||
One bit per public construct that users intentionally instantiate or configure.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `Agent`, `AgentSession`, `InMemoryHistoryProvider`, `FileHistoryProvider`.
|
||||
- `Workflow`, `WorkflowBuilder`, `FunctionalWorkflow`.
|
||||
- `FunctionTool`, `MCPStdioTool`, `MCPStreamableHTTPTool`, `MCPWebsocketTool`.
|
||||
- `LocalShellTool`, `DockerShellTool`, `ShellEnvironmentProvider`, `ShellPolicy`.
|
||||
- `FoundryChatClient`, `FoundryAgent`, `OpenAIChatClient`,
|
||||
`OpenAIChatCompletionClient`, `OpenAIEmbeddingClient`.
|
||||
|
||||
Examples that do **not** get separate bits: `Agent.run` vs
|
||||
`Agent.run_streamed`; workflow edge/executor internals such as `AgentExecutor`,
|
||||
`FunctionExecutor`, or `FanOutEdgeGroup`; `LocalShellTool` persistent vs
|
||||
stateless mode; `ShellPolicy` allowlist vs denylist configuration; `FunctionTool`
|
||||
approval mode or result parser choices.
|
||||
|
||||
Rough estimate: Python ~70-100 bits; .NET ~55-80 bits.
|
||||
|
||||
- Good, concrete and directly tied to public API use.
|
||||
- Neutral, fits within 128 bits at the current estimate, but consumes much of the
|
||||
deliberate growth reserve.
|
||||
- Bad, adds many call sites and more fingerprint specificity for v1.
|
||||
|
||||
#### F3. Construct subtype / configuration bits
|
||||
|
||||
Split important constructs by mode, transport, storage, or workflow primitive
|
||||
when that distinction matters.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `InMemoryHistoryProvider` and `FileHistoryProvider` separately.
|
||||
- `FunctionalWorkflow`, `WorkflowBuilder`, `AgentExecutor`, `FunctionExecutor`.
|
||||
- `FanOutEdgeGroup`, `FanInEdgeGroup`, `SwitchCaseEdgeGroup`.
|
||||
- `LocalShellTool` persistent, `LocalShellTool` stateless, `DockerShellTool`.
|
||||
- `MCPStdioTool`, `MCPStreamableHTTPTool`, `MCPWebsocketTool`;
|
||||
`OpenAIChatClient` vs `OpenAIChatCompletionClient`.
|
||||
|
||||
Examples that do **not** get separate bits: exact session id or persisted history
|
||||
file path; exact shell command, workdir, timeout, or output cap; exact MCP server
|
||||
command, URL, or tool names from the server; exact workflow graph shape or edge
|
||||
count; model/deployment names, prompts, tool arguments, payloads.
|
||||
|
||||
Rough estimate: Python ~110-150 bits; .NET ~85-125 bits.
|
||||
|
||||
- Good, useful where mode-level distinctions are decision-relevant.
|
||||
- Bad, trades simplicity for precision, increases fingerprint specificity, and
|
||||
may exhaust or exceed 128 bits in Python.
|
||||
|
||||
#### F4. Option / behavior flag bits
|
||||
|
||||
The most detailed framework-owned option: bits for specific modes and behavior
|
||||
switches, still excluding customer/runtime values.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- Agent streaming used vs non-streaming used.
|
||||
- `FunctionTool` `approval_mode="always_require"` vs `"never_require"`.
|
||||
- `FunctionTool` `SKIP_PARSING` / result-parser path used.
|
||||
- MCP sampling configured; MCP long-running task support used.
|
||||
- `LocalShellTool` `clean_env` / `confine_workdir`; `DockerShellTool` container
|
||||
mode.
|
||||
|
||||
Examples that do **not** get separate bits: function names wrapped by
|
||||
`FunctionTool`; approval rule arguments or approval decisions; MCP remote tool
|
||||
names or schemas; shell command text or policy regex patterns; prompt/message
|
||||
content, model names, URLs, tenant/user/session identifiers.
|
||||
|
||||
Rough estimate: Python 150+ bits; .NET 120+ bits.
|
||||
|
||||
- Good, maximum framework-owned detail.
|
||||
- Bad, exceeds or nearly exhausts 128 bits and is too detailed for v1 without a
|
||||
concrete decision that requires it.
|
||||
|
||||
### Registry sharing model
|
||||
|
||||
#### H. Per-language bit lists (chosen)
|
||||
|
||||
Each SDK owns an independent list; the decoder picks the list using the language
|
||||
already present in the UA product token.
|
||||
|
||||
- Good, **no cross-language coordination**: each SDK numbers and evolves its
|
||||
features independently; adding a Python feature never touches .NET numbering.
|
||||
- Good, no null placeholders for one-SDK features, no "same bit, same meaning"
|
||||
rule, no SDK-aware decode caveats.
|
||||
- Good, decoding is trivial: language (from UA) + version -> list -> AND.
|
||||
- Neutral, two small lists to maintain instead of one (but they were going to
|
||||
diverge anyway — the packages differ).
|
||||
|
||||
#### I. Single shared cross-language registry
|
||||
|
||||
- Good, one list, one number space.
|
||||
- Bad, forces synchronized numbering and null placeholders for features that
|
||||
exist in only one SDK, plus SDK-aware decode rules.
|
||||
- Bad, the synchronization is pure accidental complexity — **the language is
|
||||
already in the User-Agent**, so sharing the number space buys nothing.
|
||||
|
||||
### Registry maintenance
|
||||
|
||||
#### J. Package-local indexes + parity/no-overlap test (chosen)
|
||||
|
||||
- Good, each package owns private `FeatureIndex` declarations only for its own
|
||||
rows; adding an optional-provider index does not require a core release after
|
||||
the marker API exists.
|
||||
- Good, one repository test compares the package-local declarations with the
|
||||
per-language table and rejects missing rows, wrong ids, out-of-range indexes,
|
||||
and any duplicate/overlapping index.
|
||||
- Good, no build step, no generator to own.
|
||||
|
||||
#### K. Code-generate the enums from the registry
|
||||
|
||||
- Bad, a generator + drift test + schema test to maintain a short list of
|
||||
integer constants; likely justified only if v1 deliberately chooses the most
|
||||
detailed L3/L4 granularities.
|
||||
|
||||
### Representation (how the mask is rendered as text)
|
||||
|
||||
All examples below encode the same mask — bits 0, 2, 32, 48, 56 set
|
||||
(agent + workflow + sequential-orchestration + foundry.chat_client + openai, in
|
||||
the Python v1 list) = decimal `72339073309605893`.
|
||||
|
||||
#### L. Decimal — `feat=v1.72339073309605893`
|
||||
|
||||
- Good, human-familiar; trivial to parse.
|
||||
- Neutral, no visual alignment to four-bit groups; slightly longer than hex for
|
||||
large masks. No advantage over hex.
|
||||
|
||||
#### M. Hex (chosen) — `feat=v1.101000100000005`
|
||||
|
||||
- Good, compact (≤32 chars for a 128-bit mask).
|
||||
- Good, decodes with one stdlib call in every language (`int(x, 16)` /
|
||||
two 64-bit lane parses in .NET); each hex character corresponds to four
|
||||
consecutive bit positions.
|
||||
- Good, lowercase, no `0x` prefix, no leading zeros — unambiguous and stable.
|
||||
|
||||
A grouped variant such as `feat=v1.101.0001.0000.0005` was also considered.
|
||||
Separators make the value longer and must be removed before `int(x, 16)` can
|
||||
parse it, while the ordinary hex digits already preserve fixed four-bit groups.
|
||||
|
||||
#### N. Binary — `feat=v1.100000001000000000000000100000000000000000000000000000101`
|
||||
|
||||
- Good, directly shows every zero/one position.
|
||||
- Bad, grows to 128 payload characters and is difficult to scan reliably.
|
||||
|
||||
#### O. Bit-list — `feat=v1.0,2,32,48,56`
|
||||
|
||||
- Good, most directly human-readable ("which bits").
|
||||
- Bad, needs delimiter handling and grows with the number of set bits; a full
|
||||
128-bit list is substantially larger than every fixed-width representation.
|
||||
|
||||
#### P. Alphabet / base-N (e.g. Crockford base32 `feat=v1.208004000005`, base62 `feat=v1.5LJRx1i6xJ`)
|
||||
|
||||
- Good, shortest representation.
|
||||
- Bad, needs a custom alphabet + decode table on both ends; base62 is
|
||||
case-sensitive (fragile through case-normalizing intermediaries); not
|
||||
directly readable. Premature optimization for a value that is already ≤32
|
||||
chars in hex.
|
||||
|
||||
All forms are ASCII. The table shows total bytes added to the existing
|
||||
User-Agent, including the leading space and `(feat=v1.)` wrapper:
|
||||
|
||||
| Representation | Example (5 bits) | All current Python rows (63) | All current .NET rows (52) | Full 128-bit v1 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Hex | 26 | 34 | 30 | 43 |
|
||||
| Grouped hex | 29 | 39 | 34 | 50 |
|
||||
| Decimal | 28 | 38 | 34 | 50 |
|
||||
| Binary | 68 | 100 | 86 | 139 |
|
||||
| Bit-list | 23 | 189 | 156 | 412 |
|
||||
| Crockford base32 | 23 | 29 | 26 | 37 |
|
||||
| Base62 | 21 | 26 | 24 | 33 |
|
||||
|
||||
There is no defensible average before rollout, and the design does not depend on
|
||||
one: a process-global mask may eventually contain every assigned row. There is
|
||||
no smaller per-request bit budget because the bits are not request-scoped; the
|
||||
registry allocation tenet controls how many distinctions v1 assigns. Client
|
||||
processing is bounded by the fixed 128-bit width: marking performs one
|
||||
lock/atomic OR, and request-time stamping reads the mask, formats at most 32 hex
|
||||
characters, and replaces one User-Agent comment. It performs no registry scan,
|
||||
network call, or per-feature enable/disable bookkeeping.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen: **a request-time-stamped, first-party-only User-Agent `(feat=...)` token (A),
|
||||
with a 128-bit process-global monotonic accumulator (S1), per-language bit lists
|
||||
(H), package-local index enums kept honest by parity and no-overlap tests (J),
|
||||
rendered as lowercase hex (M).**
|
||||
|
||||
This is a bounded design with enough v1 headroom. A 128-bit
|
||||
**process-global, monotonic** mask accumulates from universal
|
||||
`mark_feature_used()` calls (so it spans build/start/participation activations
|
||||
that aren't bound to any service request — the per-request set model (S2) can't);
|
||||
the token is **stamped per request** only when both the client/pipeline and the
|
||||
actual HTTPS origin are approved, so custom origins and cross-origin redirects
|
||||
cannot inherit the fingerprint; each
|
||||
SDK owns an independent bit list selected by the language already in the UA; the
|
||||
mask is rendered as hex (`feat=v1.101000100000005`). The dedicated
|
||||
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` opt-out drops only the mask while
|
||||
keeping the base SDK identity/version User-Agent. Python's existing
|
||||
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to suppress its entire
|
||||
contribution, including the mask; this decision does not introduce a matching
|
||||
whole-User-Agent switch in .NET. OTel (C) is deferred — mainly because a
|
||||
broadly-emitted span attribute would leak the fingerprint into the user's
|
||||
general telemetry, against the first-party-only stance and would require
|
||||
user-side OTel setup that may still not make the data available to us — but left
|
||||
open behind the version prefix. Per-request scoping (S2), a shared registry (I),
|
||||
codegen for the initial registry (K), and the decimal/grouped-hex/binary/bit-list/
|
||||
base-N representations (L, M variant, N, O, P) are rejected as complexity or
|
||||
length the problem does not require.
|
||||
|
||||
The remaining choice before implementation is the **v1 granularity level** among
|
||||
F0-F4. This is a point-in-time decision: adding new bits later is easier than
|
||||
removing or redefining them, because removals/redefinitions require a new
|
||||
registry version and historical decode tables. For v1, prefer the least detailed
|
||||
level that answers the known product/support questions so we do not force a v2
|
||||
shortly after launch. The refreshed candidate registry uses **63 Python indexes and
|
||||
52 .NET indexes**, leaving 65 and 76 positions respectively. That headroom supports
|
||||
normal growth; it does not waive the registry's
|
||||
[allocation tenet](../specs/feature-usage-bit-registry.md#allocation-tenet).
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, adds a bounded-cost usage signal with no new data flow and few moving
|
||||
parts.
|
||||
- Good, transparent (public registry, human-decodable token) and disabled by a
|
||||
dedicated `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` mask-only opt-out. Python's
|
||||
existing whole-User-Agent opt-out also suppresses the mask.
|
||||
- Good, first-party-only + request-time stamping gives a live mask and no
|
||||
third-party fingerprint leak.
|
||||
- Good, 128 bits leaves useful v1 headroom; .NET remains lock-free by storing two
|
||||
independently atomic 64-bit lanes; per-language lists remove all cross-language
|
||||
sync; package-local enums avoid both codegen and provider→core release coupling.
|
||||
- Neutral, the token's reach equals eligible framework-configured first-party
|
||||
traffic; broader per-call signal (OTel) can be added later if needed.
|
||||
- Neutral, every set bit is a repeated Boolean observation after first use;
|
||||
request rows carrying it are not feature invocation counts.
|
||||
- Neutral, v1 granularity is intentionally a separate choice; the registry should
|
||||
start with fewer bits unless a more detailed bit answers a concrete question.
|
||||
- Bad, each feature must add an activation mark, first-party clients need a
|
||||
per-request destination-aware hook, and the registry validator must scan all
|
||||
package-local index declarations.
|
||||
|
||||
## Prior art
|
||||
|
||||
SDK telemetry-in-the-User-Agent is well-established; this design is closest to
|
||||
AWS's, and conventional in the rest. Summary of what comparable SDKs do:
|
||||
|
||||
| SDK | What's in the UA / headers | Usage-based? | Opt-out | Closest to ours? |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **AWS botocore** | structured UA with an `m/` token: a per-request set of **short feature codes** for features actually exercised (`WAITER`→`B`, `PAGINATOR`→`C`, retry mode, checksums, credential source, …) | **Yes** — registered at call time via `register_feature_id`, contextvar-scoped per request | `AWS_SDK_UA_APP_ID` sets app id (no opt-out for `m/`) | **Yes — direct analog** |
|
||||
| **OpenAI / Anthropic** (Stainless) | sidecar `X-Stainless-*` headers: lang, package version, OS, arch, runtime, runtime version; plus per-request `x-stainless-retry-count`, `x-stainless-read-timeout` | Mostly static identity (retry/timeout are per-request) | none | No (static identity) |
|
||||
| **Azure SDK** (`azure-core`) | `User-Agent: azsdk-python-{pkg}/{ver} Python/{pyver} ({platform})` | No | `AZURE_TELEMETRY_DISABLED` (tracing spans only, **not** the UA) | No |
|
||||
| **Google API core** | `x-goog-api-client: gl-python/… grpc/… gax/… gapic/…` | No | none | No |
|
||||
| **LangSmith** | `User-Agent: langsmith-py/{ver}`; usage lives in trace payloads | No (header) | opt-in via `LANGSMITH_TRACING_V2`/`LANGCHAIN_TRACING_V2`; `…HIDE_INPUTS/OUTPUTS` | No |
|
||||
|
||||
Takeaways that shaped (or validate) our choices:
|
||||
|
||||
- **AWS `m/` is the precedent for usage-based feature flags in a first-party
|
||||
User-Agent.** It validates the core idea. Its key *difference* is the encoding:
|
||||
AWS uses a **comma-separated set of 1–2 char short codes** (open-ended, no bit
|
||||
coordination, but variable length), whereas we use a fixed-width **hex
|
||||
bitmask** (compact, bounded, decode-by-AND, but needs per-language bit
|
||||
allocation). We keep the bitmask for boundedness and trivial AND-decoding;
|
||||
AWS's short-code set is recorded as a viable alternative if bit-position
|
||||
coordination ever becomes painful (it would also drop the fixed 128-bit bound).
|
||||
- **A fixed-width bitmask gives bounded token size for free.** botocore must cap
|
||||
the `m/` component at 1024 bytes and truncate at delimiter boundaries (with a
|
||||
fallback log) precisely *because* its short-code set is unbounded. Our 128-bit
|
||||
hex is ≤32 chars by construction — no size cap, no truncation logic.
|
||||
- **Scope is where we diverge most — and deliberately.** botocore collects
|
||||
features into a per-request `contextvars` set that is **reset between
|
||||
requests**, and no-ops outside a request context to prevent cross-request
|
||||
bleed. That works because every botocore feature is exercised *inside* an AWS
|
||||
service request. We are more general: some features are request-scoped (a chat
|
||||
call, an MCP tool invocation) but many are **not bound to any request**
|
||||
(workflow build/start, provider participation, hosting startup). So we use a
|
||||
**process-global, monotonic** mask (option S1), which is the only scope that can
|
||||
represent the non-request features. Our mask therefore intentionally "bleeds"
|
||||
(accumulates) for the life of the process — the opposite of botocore's reset —
|
||||
and that is the intended semantic, not the bug botocore guards against.
|
||||
- **The mechanism is private; the wire format is the contract.** botocore marks
|
||||
its whole user-agent module private and "subject to abrupt breaking changes."
|
||||
Same for us: the Python/.NET helpers are internal, and only the emitted token +
|
||||
the per-language registry tables are the stable, decodable contract.
|
||||
- **First-party-only emission** is stricter than any of the above; the closest in
|
||||
spirit is Stainless headers, which only reach the owning API. We make the
|
||||
client/pipeline allowlist explicit (initially Foundry/Azure OpenAI) rather than
|
||||
attempting to infer safety from arbitrary request URLs. Other Azure clients
|
||||
join only after telemetry access is confirmed.
|
||||
- **Opt-out naming.** `AZURE_TELEMETRY_DISABLED` is the family precedent for our
|
||||
`AGENT_FRAMEWORK_*_DISABLED` names. Separately, the cross-tool `DO_NOT_TRACK`
|
||||
convention (honored by e.g. HuggingFace Hub) is worth considering — see Open
|
||||
Questions.
|
||||
|
||||
Sources: botocore [`useragent.py`](https://github.com/boto/botocore/blob/develop/botocore/useragent.py)
|
||||
(`_USERAGENT_FEATURE_MAPPINGS`, `register_feature_id`, `_build_feature_metadata`);
|
||||
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);
|
||||
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).
|
||||
|
||||
## Registry versioning and migration (v1 → v2)
|
||||
|
||||
The token carries a **per-language** version (`feat=v1.<hex>`); a version bump is
|
||||
independent for Python and .NET.
|
||||
|
||||
- **Additive growth stays on v1 — no bump.** Allocating a new feature to a
|
||||
reserved/unused bit is backward-compatible: an older decoder simply sees an
|
||||
unknown bit and ignores it. Normal package growth never needs a new
|
||||
version.
|
||||
- **A bump (v2) is required only for breaking changes:** renumbering or
|
||||
re-partitioning existing bits, changing the *meaning* of an already-assigned
|
||||
index, or widening beyond 128-bit. Within a version an index is **never** reused or
|
||||
reassigned — that invariant is what lets old decoders stay correct.
|
||||
- **The draft 64→128 change is still v1.** No v1 token or enum has shipped, so
|
||||
this pre-implementation repartition establishes the initial contract rather
|
||||
than migrating an existing one.
|
||||
- **Mixed-version coexistence is the norm.** A fleet runs many SDK releases at
|
||||
once, so `v1` and `v2` tokens appear simultaneously for a long time (old SDKs
|
||||
keep emitting `v1`). The decoder keeps **every** published `(language,
|
||||
version)` table and selects by the token's version; the `v1` table is retained
|
||||
indefinitely for historical decode.
|
||||
- **Unknown version → do not guess.** A decoder without the `vN` table must
|
||||
record "unknown registry version" rather than decode against an older table —
|
||||
bit meanings may differ across versions, so mis-attribution is worse than
|
||||
no data.
|
||||
- **Producing v2:** publish the v2 table alongside v1, update the affected
|
||||
package-local `FeatureIndex` declarations and SDK version constant, and emit
|
||||
`v2` from the release that ships them. Prefer staying on v1 (additive) and
|
||||
reserving a clean v2 for an eventual deliberate re-partition.
|
||||
|
||||
## Limitations
|
||||
|
||||
| Limitation | Caused by (choice) | Why we accepted it |
|
||||
| --- | --- | --- |
|
||||
| **No signal for self-hosted or third-party-only traffic.** If a process never calls Azure/Foundry, we see nothing. | First-party-only emission (A) | We can't read third-party logs anyway, and must not leak a fingerprint into them. Reach traded for privacy. |
|
||||
| **Not every first-party client is stampable.** Caller-supplied `AIProjectClient` / OpenAI clients and toolkit-owned clients may not expose a supported per-request policy hook. | Supported-hook-only emission (A) | V1 does not mutate caller-owned clients or private SDK pipelines. Those features may still appear on another eligible request from the same process-global mask. |
|
||||
| **Custom origins intentionally receive no feature token.** A customer gateway may use Azure credentials or Azure-named settings but route to a non-approved origin. | Two-factor destination classification (A) | Credentials and configuration names are not proof of telemetry ownership. Unknown/custom origins and cross-origin redirects are denied by default. |
|
||||
| **No OTel / per-call signal in v1.** | OTel deferred (C) — primarily on **privacy** and availability grounds | A broadly-emitted span attribute would push the fingerprint into the user's general telemetry / third-party APM vendors, undoing the first-party-only scoping. It also requires customer/user OTel setup, and even Foundry users may not export data where we can query it. Left open only if there is a compelling reason to add. |
|
||||
| **Mask reflects "usage so far," not the whole session.** Early requests carry fewer bits than later ones. | Process-global accumulator + request-time stamping | Honest and still useful as a Boolean process-lifetime observation. Repeated request rows must not be summed as additional uses. Reading the mask at request time makes it *grow* rather than freeze. |
|
||||
| **No per-agent / per-call attribution.** The mask is one process-wide value — "this process used X", not "this agent/call used X". | Process-global monotonic scope (S1) | A deliberate choice, not a transport limit: botocore *does* per-call attribution in the UA via a per-request `contextvars` set, but many AF activations (workflow build/start, provider participation, hosting startup) occur outside the service request that later emits the token. Per-call detail remains deferred to OTel. |
|
||||
| **Shared processes intentionally carry usage across agents and tenants.** A request can include bits first set by another workload in the same worker. | Process-global monotonic scope (S1) | The token must be interpreted only as process-level "used so far," never as request/user/tenant attribution. Privacy review must explicitly accept this. |
|
||||
| **Bits are binary, sticky observations — not countable events.** Once set, a bit appears on every later eligible request from that process, so raw request counts repeat the same observation and long-lived/high-traffic processes dominate. | Monotonic mask stamped at request time | The signal supports coarse observed-feature and co-occurrence questions only. It cannot provide first-use counts, unique-process counts, request attribution, or feature invocation frequency. |
|
||||
| **Granularity may be too coarse or too detailed.** The chosen level may miss useful distinctions or create more specificity than needed. | v1 granularity choice (F0-F4) | This is the main remaining decision. Adding bits later is easier than removing/redefining them, so v1 should lean toward fewer bits that answer known questions. |
|
||||
| **.NET snapshots span two atomic lanes.** A bit can be marked between the low/high reads, so one request may omit that just-added bit. | 128-bit width without a global lock | The mask is monotonic: the snapshot cannot invent or clear a bit, and the next request includes the addition. This matches the existing "usage so far" timing semantics. |
|
||||
| **Fingerprinting risk is reduced, not eliminated.** A feature-combination mask is still a deployment signature, and it transits intermediaries (proxies/CDNs) even when first-party-scoped. | Emitting any feature-combination value | Scope + opt-out + coarse granularity mitigate it; v1 should avoid unnecessary detailed bits. |
|
||||
|
||||
## Open Questions (for decider discussion)
|
||||
|
||||
These are unresolved and should be decided before implementation:
|
||||
|
||||
1. **Which v1 granularity level (F0-F4)?** This is the primary remaining choice.
|
||||
Adding bits later is easier than removing or redefining bits, so v1 should
|
||||
choose the least detailed level that answers known questions and avoids a quick
|
||||
v2.
|
||||
2. **Privacy approval for the v1 User-Agent signal.** Before implementation,
|
||||
confirm that a transparent, opt-out, first-party-only feature-combination
|
||||
fingerprint is acceptable, including the exact client allowlist, retention,
|
||||
access, and permitted product queries. This is a rollout precondition.
|
||||
3. **When (if ever) to add the OTel path?** Held back mainly for **privacy** and
|
||||
data availability: a span attribute broadcasts the fingerprint into the user's
|
||||
general telemetry and onward to third-party APM vendors, contradicting the
|
||||
first-party-only stance, and it requires user-side OTel setup that may not make
|
||||
the data available to us even for Foundry users. It also carries a
|
||||
metric-cardinality hazard. Revisit only if the User-Agent path cannot answer a
|
||||
concrete question.
|
||||
4. **Honor the cross-tool `DO_NOT_TRACK` convention?** Several ecosystems treat
|
||||
`DO_NOT_TRACK=1` as a universal telemetry opt-out (HuggingFace Hub honors it;
|
||||
see [Prior art](#prior-art)). Should our mask opt-out also respect
|
||||
`DO_NOT_TRACK` (in addition to `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` and
|
||||
Python's pre-existing whole-UA flag)? Cheap to add and
|
||||
community-friendly, but it widens the opt-out surface and needs a clear
|
||||
precedence rule. Recommend yes; confirm with the deciders.
|
||||
|
||||
### Decided
|
||||
|
||||
- **Dedicated opt-out flag — included.** In addition to the existing
|
||||
Python `AGENT_FRAMEWORK_USER_AGENT_DISABLED` (drops the whole UA), v1 ships
|
||||
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`, which drops **only** the feature mask
|
||||
while keeping the base SDK identity/version User-Agent. This lets a
|
||||
privacy-conscious user withhold the usage signal without losing the
|
||||
support/compat value of the SDK-version header. .NET adopts the dedicated
|
||||
mask-only flag; adding a .NET whole-User-Agent switch is outside this decision.
|
||||
- **Caller-owned clients are not modified.** V1 stamps only framework-created
|
||||
clients or clients with a supported public policy/hook registration point. It
|
||||
does not patch private pipelines; injected clients are an explicit coverage
|
||||
limitation.
|
||||
- **Destination approval is explicit and redirect-aware.** An eligible pipeline
|
||||
still emits only to a reviewed HTTPS origin. Custom origins are default-deny,
|
||||
and the token is removed on an unapproved redirect hop.
|
||||
- **Telemetry does not replace transport defaults.** Framework-created OpenAI
|
||||
clients use the SDK's default async HTTP client with the request hook added,
|
||||
preserving redirect, timeout, connection-limit, and pooling behavior.
|
||||
- **Marking uses activation, not DI construction.** Operational surfaces mark on
|
||||
first real use; a constructor marks only when construction itself exercises or
|
||||
registers the capability.
|
||||
|
||||
## More Information
|
||||
|
||||
- Mechanism & API: [SPEC-004](../specs/004-feature-usage-telemetry.md)
|
||||
- Per-language bit tables, encoding, opt-out, governance: [feature-usage-bit-registry.md](../specs/feature-usage-bit-registry.md)
|
||||
- Existing accumulator pattern: `python/packages/core/agent_framework/_telemetry.py`
|
||||
- .NET emission policies: `dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs`,
|
||||
`dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs`
|
||||
@@ -1,308 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-07-24
|
||||
deciders: eavanvalkenburg, chetantoshnival, taochenosu, moonbox3, giles17
|
||||
---
|
||||
|
||||
# Python session storage and serialization
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Python does not have a broadly shared session-store API in
|
||||
`agent-framework-core`. The alpha `agent-framework-hosting` package has a small process-local `SessionStore`, but that
|
||||
type is hosting-specific, in-memory only, and unavailable to packages such as Foundry Hosting without taking a
|
||||
dependency on the hosting helper package.
|
||||
|
||||
The alpha implementation is a prototype, not a compatibility constraint. This decision may replace its location,
|
||||
names, method shape, and behavior if another design is preferable.
|
||||
|
||||
The existing file-backed persistence surfaces solve narrower problems:
|
||||
|
||||
- `FileHistoryProvider` stores conversation `Message` records, not complete `AgentSession` snapshots;
|
||||
- `FileCheckpointStorage` stores workflow checkpoints; and
|
||||
- the Responses provider stores protocol history, but not Agent Framework runtime state carried in
|
||||
`AgentSession.state`.
|
||||
|
||||
`AgentSession.to_dict()` / `from_dict()` already provide a dictionary snapshot shape. Session state may contain
|
||||
framework or application-defined objects, and `register_state_type` provides dynamic type restoration, but the
|
||||
registration and collision behavior is not yet strong enough to serve as a durable, cold-start persistence contract.
|
||||
|
||||
The framework therefore needs to decide:
|
||||
|
||||
- where a reusable in-memory and file-backed session store belongs;
|
||||
- how a complete `AgentSession` should be serialized atomically and validated;
|
||||
- how custom nested state types are registered and restored after process restart; and
|
||||
- how to provide the required readable JSON format while leaving room for an optional optimized binary format.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
### Session-store ownership and API
|
||||
|
||||
- Make session storage reusable by core, hosting, and provider packages without creating dependency cycles.
|
||||
- Keep the smallest public API that supports in-memory use, durable implementations, and application-defined stores.
|
||||
- Define the minimum async operations required for lookup, replacement, and deletion.
|
||||
- Decide explicitly whether reads return shared instances or independent snapshots suitable for branching.
|
||||
- Simpler is better
|
||||
|
||||
### Serialization and type restoration
|
||||
|
||||
- Provide readable JSON serialization as a required capability.
|
||||
- Treat an optimized binary format as a nice-to-have only when the chosen JSON implementation supports it without a
|
||||
separate state model or substantial additional complexity.
|
||||
- Perform one typed encode and decode operation per file write/read.
|
||||
- Preserve dynamic registration of nested state types by the provider modules that own them.
|
||||
- Fail before persistence when an object cannot be restored after a cold start.
|
||||
- Keep the existing serialized `{"type": "<id>", ...}` representation compatible.
|
||||
|
||||
## Decision 1: Session-store ownership and API shape
|
||||
|
||||
### Keep `SessionStore` in `agent-framework-hosting`
|
||||
|
||||
- Good: keeps the abstraction local to app-owned hosting scenarios.
|
||||
- Bad: Foundry Hosting and other packages cannot reuse it without depending on the hosting helper package.
|
||||
- Bad: a generic session snapshot store is not inherently or only a web-hosting concern.
|
||||
- Bad: durable implementations would either be duplicated or placed in an unrelated package.
|
||||
|
||||
### Add an abstract store plus separate in-memory and file implementations
|
||||
|
||||
For example, define a `SessionStore` protocol/ABC with `InMemorySessionStore` and `FileSessionStore`.
|
||||
|
||||
- Good: clearly separates the contract from implementations.
|
||||
- Good: implementation names state their storage behavior explicitly.
|
||||
- Neutral: follows a familiar repository/adapter pattern.
|
||||
- Bad: introduces an additional public type and rename for a three-method experimental API.
|
||||
- Bad: callers must choose an implementation even for the default in-memory case.
|
||||
- Bad: the abstraction adds little value while every implementation still needs the same method overrides.
|
||||
|
||||
### Move the concrete store to core and use it as the overridable base
|
||||
|
||||
Move `SessionStore` to `agent-framework-core`, retain its in-memory behavior, and implement `FileSessionStore` by
|
||||
overriding the same async methods.
|
||||
|
||||
- Good: one public type is both the useful default and the extension point.
|
||||
- Good: existing custom stores can continue subclassing and overriding `get` / `set` / `delete`.
|
||||
- Good: core and provider packages can share the API without depending on hosting helpers.
|
||||
- Good: `FileSessionStore` remains a focused subclass while the base stays free of file-system concerns.
|
||||
- Bad: the class name does not explicitly say "in memory" when used without overrides.
|
||||
|
||||
## Decision 2: Serialization and type restoration
|
||||
|
||||
Once a file-backed store exists, it needs an on-disk format and a reliable way to reconstruct the complete
|
||||
`AgentSession`, including nested framework and application-defined state. Serialization belongs to each durable store
|
||||
implementation rather than the `SessionStore` API: the default in-memory store does not serialize, and custom stores
|
||||
remain free to choose another protocol.
|
||||
|
||||
The alternatives below compare top-level snapshot validation, JSON encoding/decoding cost, and how each option
|
||||
interacts with the dynamic custom-state registry. Binary storage is not a primary selection criterion.
|
||||
|
||||
### Considered options
|
||||
|
||||
The standard-library and optimized-JSON options are not mutually exclusive. A store can default to `json` while
|
||||
accepting caller-supplied `dumps` / `loads` callables for `orjson` or another compatible implementation. This is the
|
||||
pre-msgspec `FileHistoryProvider` design; those hooks remain only as a deprecated compatibility path.
|
||||
|
||||
### Standard library `json`
|
||||
|
||||
- Good: no additional dependency and familiar readable output.
|
||||
- Good: accepts the existing dictionary snapshots without a schema.
|
||||
- Good: can remain the fallback/default behind pluggable `dumps` / `loads`.
|
||||
- Neutral: custom state restoration still requires the framework registry.
|
||||
- Bad: slower encoding and decoding than optimized native implementations.
|
||||
- Bad: provides no typed snapshot validation during file reads.
|
||||
|
||||
### Optimized drop-in JSON libraries such as `orjson`
|
||||
|
||||
- Good: substantially faster JSON encoding and decoding than the standard library.
|
||||
- Good: can preserve the existing dictionary-oriented snapshot and custom `dumps` / `loads` shape.
|
||||
- Good: can be an opt-in codec without making the optimized package a framework dependency.
|
||||
- Neutral: returns bytes when encoding, which the file stores can already handle.
|
||||
- Neutral: custom state restoration still requires the framework registry.
|
||||
- Bad: remains an untyped top-level decode; the framework must separately validate the session snapshot shape.
|
||||
- Bad: choosing one drop-in implementation as a core dependency adds a dependency without providing typed construction.
|
||||
|
||||
### Pydantic `model_dump` / `model_validate`
|
||||
|
||||
- Good: Pydantic is already a core dependency.
|
||||
- Good: a typed session snapshot model can validate top-level fields and provide `model_dump_json` /
|
||||
`model_validate_json` for file serialization.
|
||||
- Good: validation errors include useful field paths.
|
||||
- Neutral: the dynamic `state` field remains `dict[str, Any]`, so custom nested state restoration still requires the
|
||||
framework registry.
|
||||
- Neutral: the public `AgentSession` does not need to become a Pydantic model; an internal snapshot model can bridge it.
|
||||
- Bad: benchmarked encode/decode includes model construction and dumping overhead on every operation.
|
||||
- Bad: core dependency on Pydantic run the risk of us not being able to use different versions or users of the framework being unable to upgrade or having additional extra code dealing with major version bumps in Pydantic.
|
||||
|
||||
### msgspec typed/tagged unions only
|
||||
|
||||
- Good: msgspec owns validation and reconstruction end to end.
|
||||
- Neutral: works well for a closed set of framework-owned `msgspec.Struct` types.
|
||||
- Bad: every external type must be known when the decoder schema is constructed; dynamic registration is lost.
|
||||
|
||||
### msgspec codecs plus an explicit dynamic registry
|
||||
|
||||
- Good: one typed file encode/decode and dynamic nested custom types.
|
||||
- Good: it satisfies the required readable JSON format.
|
||||
- Neutral: the same typed snapshot can also support optional MessagePack as a low-cost implementation detail.
|
||||
- Good: the registry can enforce stable IDs, codec completeness, and collision handling.
|
||||
- Neutral: a single state-payload hook still recursively applies registry codecs.
|
||||
- Bad: msgspec cannot infer dynamic types from JSON without the framework's type tags.
|
||||
|
||||
## Benchmark Evidence
|
||||
|
||||
A benchmark using a large `AgentSession` with 2,000 `Message` objects stored through
|
||||
`InMemoryHistoryProvider`, nested standard dictionaries, registered custom classes, and registered Pydantic models
|
||||
measured the complete `AgentSession.to_dict()` / codec / `AgentSession.from_dict()` path.
|
||||
The reproducible harness is
|
||||
[`python/scripts/session_serialization_benchmark.py`](../../python/scripts/session_serialization_benchmark.py):
|
||||
|
||||
```bash
|
||||
cd python
|
||||
uv run --with orjson python scripts/session_serialization_benchmark.py
|
||||
```
|
||||
|
||||
| Codec | File size | Encode median (ms) | Decode median (ms) | Round-trip median (ms) | Disk round-trip median (ms) |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Standard library JSON | 1.57 MiB | 33.503 | 14.316 | 55.261 | 75.226 |
|
||||
| orjson | 1.57 MiB | 25.808 | 11.754 | 39.398 | 63.319 |
|
||||
| Pydantic JSON | 1.57 MiB | 28.330 | 18.344 | 53.522 | 77.096 |
|
||||
| msgspec JSON | 1.57 MiB | 26.019 | 11.379 | **38.060** | 62.230 |
|
||||
| msgspec MessagePack | **1.45 MiB** | **25.134** | **11.201** | 38.512 | **58.112** |
|
||||
|
||||
The JSON encodings produced the same 1.57 MiB file size. msgspec JSON had the best median JSON round-trip latency,
|
||||
slightly ahead of orjson, while also supporting typed top-level decoding. Pydantic validation added measurable decode
|
||||
and disk-round-trip overhead without eliminating the dynamic state registry.
|
||||
|
||||
MessagePack reduced file size to 92.2% of JSON (about 7.8% smaller) and produced the best encode, decode, and disk
|
||||
round-trip medians. Its in-memory round-trip median was effectively tied with msgspec JSON. This supports offering it
|
||||
as a nice-to-have, but it is not required to justify choosing msgspec for JSON.
|
||||
|
||||
These results are workload- and machine-dependent. The small differences between optimized JSON implementations are
|
||||
not the basis for the architectural choice. The benchmark instead confirms that the typed design does not impose a
|
||||
material regression for this representative payload:
|
||||
|
||||
- use msgspec JSON as the readable default;
|
||||
- optionally offer msgspec MessagePack when storage size or disk latency matters;
|
||||
- retain the explicit registry for dynamic custom state in both formats;
|
||||
- do not add orjson solely for a small JSON performance difference without typed decoding; and
|
||||
- do not use Pydantic as the file codec when its validation overhead does not replace the registry.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### Decision 1: Move the concrete overridable store to core
|
||||
|
||||
`SessionStore` moves to `agent-framework-core` as an experimental public API. It remains a concrete in-memory store and
|
||||
the default used by `AgentState` in the `hosting` package. Its async `get`, `set`, and `delete` methods remain overridable for custom storage
|
||||
implementations.
|
||||
|
||||
`FileSessionStore` subclasses `SessionStore` and provides durable atomic file persistence. No separate
|
||||
`InMemorySessionStore`, protocol, or ABC is introduced. `agent-framework-hosting` consumes the core type and no longer
|
||||
owns or re-exports `SessionStore` (this will be a breaking change in the `hosting` package).
|
||||
|
||||
Actual `SessionStore` and `FileSessionStore` operations mark Python feature-usage index 17,
|
||||
`core.session_store`, following ADR-0033's use-not-presence policy. Construction and import alone do not mark the bit.
|
||||
|
||||
`SessionStore` accepts opaque non-empty keys so custom backends can use their native key contracts. `FileSessionStore`
|
||||
accepts opaque keys up to 128 characters and encodes values that are not portable filename stems; this supports
|
||||
provider IDs such as `telegram:<bot-id>:<chat-id>` without permitting path traversal. `AgentState` remains
|
||||
storage-agnostic and passes keys through unchanged; each store implementation owns backend-specific validation or
|
||||
normalization. Protocol-specific hosts such as Foundry may still derive their own stable storage key before calling the
|
||||
store.
|
||||
|
||||
Foundry Hosting exposes an experimental `FoundrySessionStore`, which is the
|
||||
default `ResponsesHostServer` store when hosted; local hosting defaults to the
|
||||
in-memory `SessionStore`. `FoundrySessionStore` currently subclasses
|
||||
`FileSessionStore`, stores snapshots under
|
||||
`/.sessions/<user-id>/<conversation-id-or-response-id>.json`, and derives the
|
||||
validated user partition from
|
||||
`azure.ai.agentserver.core.get_request_context()`. A Foundry session controls
|
||||
hosted compute and filesystem lifetime and may host multiple users and
|
||||
Responses conversations, so its ID is not used as the MAF session identifier.
|
||||
Stored-conversation requests read and write one snapshot under
|
||||
`conversation_id`. Response-chain requests read under `previous_response_id`
|
||||
and write the updated, loaded MAF session under the current `response_id`, which
|
||||
allows branching without overwriting the parent snapshot. Because Foundry does
|
||||
not infer `agent_session_id` from `previous_response_id`, response-chain callers
|
||||
must also reuse the prior response's hosted session ID so the request reaches
|
||||
the same persistent `$HOME`; conversation objects bind a stable hosted session
|
||||
automatically.
|
||||
The Foundry-specific type is the host configuration seam; its implementation
|
||||
may later move from files to a Foundry storage API without changing the generic
|
||||
core store contract. The session file API maps `/` to the hosted `$HOME`
|
||||
directory, so this API path is persisted on disk under `$HOME/.sessions`.
|
||||
|
||||
### Decision 2: Use msgspec codecs plus an explicit dynamic registry
|
||||
|
||||
Chosen option: **msgspec codecs plus an explicit dynamic registry**.
|
||||
|
||||
`FileSessionStore` uses a typed internal `msgspec.Struct` snapshot with reusable JSON and MessagePack encoders/decoders.
|
||||
JSON is the required and default format. Because msgspec can reuse the same typed snapshot and registry hooks,
|
||||
`serialization_format="msgpack"` is also exposed as an optional compact binary convenience. The complete state
|
||||
dictionary is wrapped in one custom field; its encode/decode hooks recursively translate explicitly registered types
|
||||
to and from the existing tagged mappings in either format.
|
||||
|
||||
The dependency range is `msgspec>=0.20.0,<0.22`: version 0.20.0 added Python 3.14 support, and the upper bound limits
|
||||
core to the tested 0.20/0.21 minor lines.
|
||||
|
||||
Three dependency placements were considered:
|
||||
|
||||
1. Make msgspec a standard core dependency.
|
||||
2. Make msgspec optional in core but standard in Foundry hosting.
|
||||
3. Make msgspec optional in both packages.
|
||||
|
||||
Option 3 moves installation failures to application developers even though durable session persistence is required for
|
||||
the primary `ResponsesHostServer` API to preserve Agent Framework state. Option 2 removes that burden from Foundry
|
||||
hosting but makes core's shared `_sessions` module and public types conditionally defined or lazily imported without
|
||||
removing msgspec from the default Foundry installation. Option 1 is therefore selected: msgspec is a standard core
|
||||
dependency, giving both core file providers and Foundry hosting one predictable implementation path.
|
||||
|
||||
Core already depends on the native `pydantic-core` extension, so native-wheel availability is not a new packaging
|
||||
constraint. The msgspec project is also actively tracking upcoming Python support; its merged
|
||||
[`Add 3.15-dev to CI` PR](https://github.com/msgspec/msgspec/pull/1037) exercises Python 3.15 development builds. This gives confidence that they will add support for new python version quickly.
|
||||
|
||||
The public `AgentSession` remains a normal framework class. The msgspec Struct is an internal persistence DTO rather
|
||||
than the inheritance base for runtime sessions. The Struct gives persistence one typed encode/decode operation, validates
|
||||
the snapshot envelope, and carries an explicit payload version. The benchmark's small timing spread was not used to
|
||||
choose the Struct.
|
||||
|
||||
`register_state_type` supports stable type IDs and optional codecs, rejects collisions, and provides defaults for
|
||||
`to_dict` / `from_dict` classes and Pydantic models. Type IDs share one process-wide registry, so provider packages
|
||||
should use stable package-qualified identifiers and register their own state types at module import time; consumers do
|
||||
not need to know those implementation details. One recursive serializer is shared by `AgentSession.to_dict()` and the
|
||||
durable codecs. The established implicit Pydantic registration behavior remains temporarily for compatibility, but now
|
||||
emits `DeprecationWarning`. Same-process round-trips continue to work; cold-start deserialization is not guaranteed
|
||||
without explicit provider registration. Unknown persisted type IDs remain raw dictionaries.
|
||||
|
||||
File snapshots are quarantined only when their bytes cannot be parsed as the selected JSON or MessagePack format.
|
||||
Schema errors, unsupported snapshot versions, and registered state-decoder failures leave the original file in place so
|
||||
an application fix, rollback, or compatible reader can recover it.
|
||||
|
||||
`FileHistoryProvider` also adds msgspec JSON as its default JSON Lines codec. It supports the same explicit
|
||||
`serialization_format="msgpack"` choice using length-prefixed append-only MessagePack records. Its existing `dumps` /
|
||||
`loads` extension points remain temporarily for JSON compatibility, emit `DeprecationWarning` when supplied, and do
|
||||
not apply to MessagePack. New code uses the built-in codecs. The default JSON reader falls back to the standard library
|
||||
for legacy JSON Lines containing `NaN` or infinity, and writes those non-finite values with the standard library so
|
||||
existing history semantics are preserved.
|
||||
|
||||
## Follow-up Work
|
||||
|
||||
Audit the remaining file-backed stores to determine whether they benefit from the same typed msgspec treatment and
|
||||
optional JSON / MessagePack formats. `FileCheckpointStorage` is the first candidate because it persists large,
|
||||
structured workflow state and currently uses JSON plus custom checkpoint value encoding. Its existing
|
||||
`WorkflowCheckpoint.version` field already provides a payload-shape discriminator.
|
||||
|
||||
Checkpoint migration should be reader-first. A compatibility release can detect the codec from the first byte, widen
|
||||
the two `glob("*.json")` readers to discover future formats, and continue writing only JSON. A later release can add
|
||||
opt-in MessagePack writes while retaining JSON as the default. The payload `version` should describe the checkpoint
|
||||
shape rather than the codec, which is discoverable from the bytes. MessagePack should not become the default while
|
||||
mixed-version fleets may share one checkpoint directory: older readers silently ignore non-JSON files and could resume
|
||||
from no checkpoint instead of surfacing an incompatibility.
|
||||
|
||||
`MemoryContextProvider` is another candidate because its file-backed path combines `MemoryFileStore` state with
|
||||
transcript files and still exposes `history_dumps` / `history_loads` passthroughs to the deprecated
|
||||
`FileHistoryProvider` codec hooks.
|
||||
|
||||
The follow-up should measure real framework payloads before changing formats, preserve compatibility or define a clear
|
||||
migration path for existing files, and consider whether each store needs readable JSON, compact binary storage, append
|
||||
semantics, or atomic whole-file replacement. Other candidates include file-backed todo state, but each should be
|
||||
evaluated independently rather than adopting msgspec by default solely for consistency.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: MohammadHaroonAbuomar
|
||||
date: 2026-08-07
|
||||
deciders: agent-framework .NET maintainers
|
||||
---
|
||||
|
||||
# .NET agent-hooks enforcement: composed factory over three seams
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The [AGENT-HOOKS-0.1](https://github.com/responsibleai/agent-hooks) interception contract shipped for Python as a first-class experimental core feature (#7515): a middleware bundle emitting eight interception points with three-verdict, fail-closed enforcement, transform write-back, buffered streaming, and verdict-before-durability persistence gating. The .NET side needs the same semantics, but the .NET framework has no category-based middleware lists — interception is decorator composition (`DelegatingAIAgent`, Microsoft.Extensions.AI `DelegatingChatClient`, the function-invocation middleware seam). How should the contract's indivisibility and enforcement properties be realized in that model?
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Identical enforcement semantics to the merged Python feature (same spec, same fail-closed rules), diverging only where the .NET seam model requires it — never by weakening an enforcement property.
|
||||
- Partial installation of the enforcement must be impossible or loudly rejected, not silently degraded.
|
||||
- Denied content must never become durable; transformed content must persist post-transform.
|
||||
- No changes to existing framework source; the optional native-runtime dependency (`ResponsibleAI.AgentHooks`) must not be referenced by core packages.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**A single factory (`AsAIAgentWithAgentHooks`, per-run and host-owned-session overloads) in a new package `Microsoft.Agents.AI.AgentHooks` composes the full enforcement itself** instead of exposing middleware values:
|
||||
|
||||
- **Seam order (fixed by construction):** `AgentHooksAgent` (agent seam: `agent_startup`/`input`/`output`/`agent_shutdown`, per-run `AsyncLocal` state, buffered streaming, persistence gate) → framework function-invocation middleware (`pre_tool_call`/`post_tool_call`) → `ChatClientAgent` with its default pipeline → `AgentHooksChatClient` **below** `FunctionInvokingChatClient` (so `pre_model_call`/`post_model_call` bracket every model service call of the tool loop individually).
|
||||
- **Indivisibility:** the seam decorators are `internal`; only the factory composes them. Two pipeline-replacement affordances of `ChatClientAgent` are rejected loudly (fail closed): a caller-supplied per-run `ChatClientFactory` (the framework's own function-middleware factory is recognized and allowed — it wraps, not replaces), and a supplied chat client that already contains a `FunctionInvokingChatClient` (it would execute tools below the verdicts).
|
||||
- **Verdict-before-durability:** end-of-run history and context-provider writes defer behind the `output` verdict via gating provider wrappers installed by the factory (dropped on deny, flushed post-transform with verdicted-message substitution for streamed runs). The implicit default `InMemoryChatHistoryProvider` is materialized and gated, with the history-conflict flags set to mimic implicit-default semantics. Per-service-call persistence sits above the chat seam, so it is covered by its own `post_model_call` verdict. Per-run provider overrides are wrapped in both `AdditionalProperties` dictionaries, copy-on-write. Nested agents persist inline at their own boundaries (they have their own providers) — no run-identity bookkeeping is needed, unlike Python.
|
||||
- **Fail-closed error behavior:** interceptor crashes/timeouts surface as `host_error:*` denies; enforcement-layer failures at the tool seam halt the run through `FunctionInvocationContext.Terminate` (the loop's only loud escape — thrown exceptions are converted to tool errors by the loop, which would fail open); wire projections run inside the guarded blocks; failure notifications to providers are redacted (empty request messages) once a deny/halt stands.
|
||||
- **Streaming:** fully buffered per the spec's `buffered_output` semantics — zero egress ahead of a verdict; transformed responses re-derive the released updates (preserving continuation tokens) so egress never diverges from verdicted content.
|
||||
|
||||
### Considered Alternatives
|
||||
|
||||
- **Port Python's middleware-value model (a `MiddlewareBundle` type):** rejected — .NET has no middleware list to put a bundle into; indivisibility via runtime validation is weaker than construction ownership.
|
||||
- **Core-framework persistence gate (as Python added in `_sessions.py`):** rejected — unnecessary in .NET; construction ownership of the provider instances gives the same property with zero core changes.
|
||||
- **Per-run `ChatClientFactory` as the chat-seam install point:** rejected — it wraps the whole pipeline above the function-invocation loop, so per-model-call points would be impossible.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Good: zero existing-source changes; the optional native dependency is isolated in one leaf package; enforcement properties are structural rather than convention-based.
|
||||
- Accepted: the package ships in the release solution filter as an **alpha** package (maintainer decision on the PR) — the version suffix follows the maturity of the `ResponsibleAI.AgentHooks` dependency it is built on, and the whole surface stays `[Experimental]`; a sample follows once the API shape settles.
|
||||
- Known limitations (documented on the factory): hosted (service-executed) tools never reach the function seam and are intercepted via the `post_model_call` content projection; service-managed (conversation-id) history is durable at the service and ungateable; the deferred-OTel decorator sits above the chat seam, so sensitive-data request spans observe pre-transform content; a chat-seam projection failure fails the run closed but without a synthesized `host_error` record (SDK affordance gap, responsibleai/agent-hooks#70).
|
||||
- The trust model is the spec's: cooperative contract, not a security boundary — the misuse rejections catch accidental foot-guns loudly, not in-process adversaries.
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: rogerbarreto
|
||||
date: 2026-08-21
|
||||
deciders: rogerbarreto
|
||||
consulted: Tao Chen, Sergey M., Ben Thomas, Shanmukha
|
||||
informed: Agent Framework .NET team
|
||||
---
|
||||
|
||||
# Resilient long-running agents in Microsoft.Agents.AI.Foundry.Hosting
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The Foundry Hosted Agents platform can run a hosted agent as a long job that continues when no
|
||||
client is connected, and that the platform restarts after the container crashes or is recycled.
|
||||
On restart the platform re-invokes the handler with the same input, sets `ResponseContext.IsRecovery`
|
||||
to true, and supplies the last durable `ResponseObject` snapshot as `PersistedResponse`. The
|
||||
snapshot is not itself a workflow checkpoint. For workflow agents, hosting records the ID of the
|
||||
matching workflow checkpoint inside AgentServer internal response metadata before it persists the
|
||||
response snapshot.
|
||||
|
||||
This applies only to **background** requests (`background=true`) whose `store` value is omitted or
|
||||
true. Omitted `store` uses the Responses API default of true. Foreground requests and explicit
|
||||
`store=false` requests have no crash-recovery contract.
|
||||
|
||||
Python currently supports resilient background execution for workflow agents and steering for
|
||||
single agents. .NET hosting must offer the same opt-in capabilities on top of the durable session
|
||||
and checkpoint storage introduced for Foundry state stores (PR #7649).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Match the Python recovery contract.
|
||||
- Pair each persisted workflow response snapshot with the exact workflow checkpoint it represents.
|
||||
- Opt-in and off by default; non-resilient hosts pay nothing.
|
||||
- Prefer workflows: they already checkpoint between supersteps.
|
||||
- Keep a lean API on `FoundryResponsesOptions`, forwarded to `ResponsesServerOptions`.
|
||||
- Persist agent sessions through the Foundry state store (or its local fallback), not a second disk layout.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **turn resilience on through the existing handler and registration path**.
|
||||
|
||||
### Public surface
|
||||
|
||||
`FoundryResponsesOptions.ResilientBackground` and `FoundryResponsesOptions.SteerableConversations`
|
||||
are forwarded to `ResponsesServerOptions` so the AgentServer SDK enables recovery and steering.
|
||||
This forwarding must happen in the callback passed directly to `AddResponsesServer`. The SDK makes
|
||||
two process-level choices during that registration call: whether local SSE replay uses durable
|
||||
storage and whether the conversation task accepts steering. Configuring the options only through
|
||||
the later `IOptions` pipeline is too late for those choices.
|
||||
|
||||
The first `AddFoundryResponses` call owns this host-level configuration. Repeated calls do not
|
||||
register another Responses server or redefine its resilience mode. Later calls can still configure
|
||||
MAF-only options such as `AllowStoredOutputEnabled`; attempting to enable an AgentServer task
|
||||
feature after the first call fails immediately instead of leaving AgentServer and MAF with
|
||||
different settings.
|
||||
|
||||
```csharp
|
||||
builder.Services.AddFoundryResponses(agent, configure: o => o.ResilientBackground = true);
|
||||
```
|
||||
|
||||
### Handler contract on recovery
|
||||
|
||||
When `IsRecovery` is true:
|
||||
|
||||
1. Seed `ResponseEventStream` from the `PersistedResponse` that AgentServer provides. This preserves
|
||||
its response fields, completed output items, and internal metadata.
|
||||
2. When the snapshot contains `_last_checkpoint_id` and a persisted workflow `AgentSession` was
|
||||
restored, select that exact checkpoint as the workflow resume point. This prevents a newer
|
||||
checkpoint already present in workflow storage from being combined with an older response
|
||||
snapshot. Foundry Hosting obtains the experimental `WorkflowSessionCheckpointRecovery` service
|
||||
from the restored `AgentSession`; the internal `WorkflowSession` remains hidden. The resumed run
|
||||
continues the work already queued in that checkpoint without sending a new `TurnToken` to the
|
||||
start executor.
|
||||
3. When `_last_checkpoint_id` is absent, retain the checkpoint already referenced by the restored
|
||||
session. This covers a crash after the workflow wrote its first checkpoint but before AgentServer
|
||||
persisted the first paired response snapshot. If the process stopped before the first session
|
||||
save, no resumable MAF state exists, so the handler re-injects the original input instead of
|
||||
invoking a fresh session with no messages. A regular agent has no equivalent within-turn workflow
|
||||
checkpoint, so recovery remains best-effort and depends on its serialized session state.
|
||||
4. On graceful shutdown of a resilient turn, call `ExitForRecoveryAsync` instead of emitting
|
||||
incomplete. The AgentServer shutdown token is linked to the token passed into the MAF agent so
|
||||
long-running model, tool, and workflow operations stop promptly. The handler also checks
|
||||
`IsShutdownRequested` after each agent update, because an agent may consume cancellation and
|
||||
return normally instead of throwing. If shutdown becomes visible after the agent advanced but
|
||||
before the corresponding event was emitted, the final session save is skipped. Recovery uses
|
||||
the last session snapshot that corresponds to output already handed to AgentServer.
|
||||
5. For non-workflow agents, best-effort save the agent session after each
|
||||
`ResponseOutputItemDoneEvent`, with an authoritative end-of-turn save in `finally` (skipped when
|
||||
the turn failed). Workflow agents use only the paired superstep path below for incremental saves,
|
||||
so their persisted session cannot advance independently through ordinary output-item saves.
|
||||
|
||||
### Workflow response checkpoint alignment
|
||||
|
||||
When `OutputConverter` receives a `SuperStepCompletedEvent` with a new workflow checkpoint ID:
|
||||
|
||||
1. Close any response output item still open for that superstep.
|
||||
2. Compare the new ID with `_last_checkpoint_id` in `ResponseEventStream.InternalMetadata`. If they
|
||||
match, do nothing.
|
||||
3. Save the `AgentSession` that references the new workflow checkpoint. If this save fails, keep the
|
||||
prior response snapshot and metadata. The turn continues, and a later workflow checkpoint or the
|
||||
final save can try again.
|
||||
4. Write the new ID to `_last_checkpoint_id`.
|
||||
5. Emit `response.in_progress` with the updated response state. AgentServer beta.8 tracks a
|
||||
separate authoritative response object, so this event copies the internal metadata into the
|
||||
snapshot that its checkpoint operation persists. The reserved metadata remains stripped from
|
||||
client payloads.
|
||||
6. Yield `ResponseEventStream.Checkpoint()`. AgentServer persists the response snapshot before it
|
||||
resumes the handler.
|
||||
|
||||
The workflow checkpoint itself is already durable before `SuperStepCompletedEvent` is emitted. The
|
||||
session save and response checkpoint therefore establish a recoverable boundary with three matching
|
||||
parts: completed response output, serialized session state, and workflow checkpoint ID.
|
||||
|
||||
If a crash occurs after the workflow creates a newer checkpoint but before the next response
|
||||
checkpoint, recovery deliberately uses the older ID from `PersistedResponse`. The workflow may
|
||||
repeat work after that older boundary, but it does not duplicate output already present in the
|
||||
response snapshot or lose output by resuming ahead of it.
|
||||
|
||||
### Handler contract on steering
|
||||
|
||||
When a second input arrives for an active steerable conversation:
|
||||
|
||||
1. AgentServer returns a response with `status=queued`, records the input, increments
|
||||
`PendingInputCount` on the active handler context, and signals that handler's cancellation token.
|
||||
2. The superseded handler invocation has `IsSteeredTurn=false`. If a cancellation-aware MAF
|
||||
operation throws `OperationCanceledException`, Foundry Hosting uses `PendingInputCount > 0` to
|
||||
distinguish steering from shutdown and client cancellation.
|
||||
3. Foundry Hosting completes the superseded response cleanly and saves its `AgentSession` with a
|
||||
non-cancelled save token. This gives the queued turn the latest committed MAF state.
|
||||
4. AgentServer invokes the handler again with `IsSteeredTurn=true`. This is not crash recovery:
|
||||
`IsRecovery=false`, so the new input is converted to MAF messages normally. The same
|
||||
`conversation_id` resolves the same persisted `AgentSession`.
|
||||
|
||||
No special MAF branch is required merely because `IsSteeredTurn=true`. The classification is
|
||||
available for handlers that need different application behavior; the generic adapter treats the
|
||||
drained input as the next normal turn on the same session.
|
||||
|
||||
Steering does not create a response checkpoint merely because another input was queued. Completed
|
||||
workflow supersteps have already been paired with response checkpoints. An interrupted superstep
|
||||
has no new `SuperStepCompletedEvent`, so its partial output and session state do not advance the
|
||||
paired recovery boundary. The superseded response still reaches a terminal `completed` event.
|
||||
|
||||
### State ownership
|
||||
|
||||
| State | Owner | Recovery purpose |
|
||||
|---|---|---|
|
||||
| Resilient task, SSE events, `ResponseObject` snapshots, `_last_checkpoint_id` | AgentServer | Re-invoke the handler and identify the workflow checkpoint represented by each response snapshot |
|
||||
| Serialized `AgentSession` | Foundry Hosting | Restore agent-owned state and the workflow checkpoint reference |
|
||||
| Workflow execution checkpoints | Workflow runtime through `FoundryJsonCheckpointStore` | Restore executors, queued messages, pending requests, and workflow state |
|
||||
|
||||
The handler calls `ResponseEventStream.Checkpoint()` only after a workflow superstep supplies a new
|
||||
checkpoint ID and the matching `AgentSession` save succeeds. `PersistedResponse.Output.Count` is not
|
||||
the workflow cursor. `_last_checkpoint_id` is the explicit link between the response snapshot and
|
||||
workflow storage.
|
||||
|
||||
### Relationship to durable storage (PR #7649)
|
||||
|
||||
Sessions and workflow checkpoints already go through `FoundryAgentSessionStore` /
|
||||
`FoundryJsonCheckpointStore`. AgentServer separately owns resilient task records, response snapshots,
|
||||
and SSE event replay. Resilience does not invent another store; it coordinates handler re-entry with
|
||||
the existing session and workflow stores.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Samples: `Hosted-Workflow-Resilient`, `Hosted-Workflow-Resilient-Long-Running`, and
|
||||
`Hosted-Steering`.
|
||||
- `Using-E2E-Resilience` runs the complete local crash-recovery demonstration in one console:
|
||||
it consumes the server through a MAF agent created by `AIProjectClient`, force-kills the process,
|
||||
restarts it, reconnects with a sequence-aware `ResponseContinuationToken`, then uses a third call
|
||||
on the same agent and session without a sequence cursor to replay the full stream. It validates
|
||||
the exact final countdown against the client accumulator and cursor-free replay.
|
||||
- Handler-level tests cover recovery input skip, consumption of an available response snapshot,
|
||||
response checkpoint deduplication by workflow checkpoint ID, and session-save failure that keeps
|
||||
the prior paired boundary.
|
||||
- A local two-lifetime integration test starts a real Responses host, persists a MAF
|
||||
`AgentSession`, stops the host, starts a new host over the same local AgentServer state, and
|
||||
verifies that the same response completes without re-injecting the original input.
|
||||
- A deterministic countdown recovery test interrupts a workflow after outputs `6`, `5`, and `4`,
|
||||
starts a new host, and verifies the final output is exactly `6`, `5`, `4`, `3`, `2`, `1`,
|
||||
`Countdown complete.` with no missing or duplicated items.
|
||||
- A local steering integration test sends two real HTTP turns through AgentServer and the MAF
|
||||
adapter. It verifies `queued`, serial execution, delivery of the steering input, and reuse of the
|
||||
persisted session.
|
||||
- Live Foundry tests cover background continuation without client traffic, hard process
|
||||
termination through `Environment.Exit`, recovery in a different process incarnation, transient
|
||||
`404`/`424` polling responses during replacement, and long-running steering on the same
|
||||
conversation.
|
||||
- The checkpoint-index optimistic-concurrency retry count is configurable through
|
||||
`FoundryJsonCheckpointStore`, with a default of eight attempts.
|
||||
- Package floor: Azure.AI.AgentServer Core beta.28, Invocations beta.6, Responses beta.8.
|
||||
@@ -8,8 +8,6 @@
|
||||
- **Context Provider Pattern** - `SecureAgentConfig` extends `ContextProvider`, injecting tools, instructions, and middleware automatically
|
||||
- **Automatic Variable Hiding** - UNTRUSTED content is automatically hidden without requiring manual intervention
|
||||
- **Per-Item Embedded Labels** - Tools return `list[Content]` with `Content.from_text()` for proper label propagation
|
||||
- **SecureMCPToolProxy Auto-Labeling** - MCP tools are labeled automatically from MCP `ToolAnnotations` hints
|
||||
- **MCP `_meta.ifc` Support** - Per-result IFC labels from servers (for example GitHub MCP with `X-MCP-Features: ifc_labels`) are parsed and enforced
|
||||
- **SecureAgentConfig** - One-line secure agent configuration via `context_providers=[config]`
|
||||
- **Data Exfiltration Prevention** - `max_allowed_confidentiality` prevents sensitive data leakage
|
||||
- **Message-Level Label Tracking** (Phase 1) - Track labels on every message in the conversation
|
||||
@@ -25,7 +23,6 @@ The FIDES defense system consists of seven main components:
|
||||
5. **Security Tools** - Specialized tools for safe handling of untrusted content (`quarantined_llm`, `inspect_variable`)
|
||||
6. **SecureAgentConfig** - Context provider for easy secure agent configuration
|
||||
7. **Message-Level Label Tracking** - Track labels on every message in the conversation (Phase 1)
|
||||
8. **MCP Tool/Result Label Integration** - MCP hint-based tool labeling and `_meta.ifc` result label parsing
|
||||
|
||||
## Implementation Details
|
||||
|
||||
@@ -187,17 +184,6 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
### 7. MCP Labeling Pipeline (Hints + `_meta.ifc`)
|
||||
|
||||
FIDES now secures remote MCP integration end-to-end:
|
||||
|
||||
- **Tool labels from hints**: `apply_mcp_security_labels(...)` maps MCP hints (`readOnlyHint`, `openWorldHint`) to FIDES tool properties.
|
||||
- **Safe sink defaults**: tools not explicitly marked `readOnlyHint=True` are treated as potential sinks and receive `max_allowed_confidentiality=public`.
|
||||
- **Result labels from metadata**: MCP result `_meta` is propagated via `__mcp_result_meta__`; `_meta.ifc` is parsed into `security_label` per result item.
|
||||
- **`SecureMCPToolProxy` convenience**: wraps MCP tools/URLs and applies this labeling automatically on connect.
|
||||
|
||||
This behavior is used with the GitHub MCP server when `X-MCP-Features: ifc_labels` is passed, which causes the server to return IFC labels in `_meta` (for example `{"ifc": {"integrity": "untrusted", "confidentiality": "public"}}`).
|
||||
|
||||
## Security Properties
|
||||
|
||||
### Deterministic Defense
|
||||
|
||||