Merge branch 'main' into copilot/red-green-unit-tests-issue-5873

This commit is contained in:
Roger Barreto
2026-06-08 18:32:59 +01:00
committed by GitHub
Unverified
553 changed files with 36660 additions and 14744 deletions
+6 -2
View File
@@ -1,15 +1,19 @@
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye",
"image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm",
"features": {
"ghcr.io/va-h/devcontainers-features/uv:1": {},
"ghcr.io/devcontainers/features/azure-cli:1.2.8": {}
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
"ghcr.io/devcontainers/features/copilot-cli:1": {}
},
"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"
+1 -1
View File
@@ -8,7 +8,7 @@ ignorePatterns:
- pattern: "./blob"
- pattern: "./issues"
- pattern: "./discussions"
- pattern: "./pulls"
- pattern: "./pull"
- pattern: "https:\/\/platform.openai.com"
- pattern: "http:\/\/localhost"
- pattern: "http:\/\/127.0.0.1"
@@ -0,0 +1,64 @@
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
+181
View File
@@ -0,0 +1,181 @@
// 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,
};
+341
View File
@@ -0,0 +1,341 @@
// 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);
});
});
@@ -121,6 +121,9 @@ 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
with:
@@ -191,6 +194,9 @@ jobs:
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)) }}
@@ -365,6 +371,9 @@ 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
with:
@@ -452,6 +461,9 @@ 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
with:
+83
View File
@@ -0,0 +1,83 @@
name: Limit community pull requests
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
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
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Check PR author team membership
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
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,
});
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
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
steps:
- name: Checkout scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Enforce open PR limit
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
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,
});
@@ -23,6 +23,14 @@ jobs:
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
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
+42 -1
View File
@@ -474,6 +474,45 @@ jobs:
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
timeout-minutes: 60
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_COPILOT_TIMEOUT: "120"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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: 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
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
@@ -490,6 +529,7 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
runs-on: ubuntu-latest
defaults:
@@ -553,7 +593,8 @@ jobs:
python-tests-functions,
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos
python-tests-cosmos,
python-tests-github-copilot
]
steps:
- name: Fail workflow if tests failed
+57
View File
@@ -40,6 +40,7 @@ jobs:
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
@@ -85,6 +86,8 @@ 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'
@@ -658,6 +661,58 @@ jobs:
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
timeout-minutes: 60
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_COPILOT_TIMEOUT: "120"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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: 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
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
@@ -674,6 +729,7 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
runs-on: ubuntu-latest
defaults:
@@ -735,6 +791,7 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
steps:
- name: Fail workflow if tests failed
@@ -8,6 +8,7 @@ on:
permissions:
contents: read
actions: read
pull-requests: write
jobs:
@@ -23,7 +24,7 @@ jobs:
- name: Download coverage report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ github.token }}
run-id: ${{ github.event.workflow_run.id }}
path: ./python
merge-multiple: true
@@ -38,9 +39,9 @@ jobs:
echo "PR number file 'pr_number' is missing or empty"
exit 1
fi
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"
PR_NUMBER=$(cat pr_number)
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::PR number file contains invalid content"
exit 1
fi
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
@@ -48,7 +49,7 @@ jobs:
id: coverageComment
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
github-token: ${{ github.token }}
issue-number: ${{ env.PR_NUMBER }}
pytest-xml-coverage-path: python/python-coverage.xml
title: "Python Test Coverage Report"
+1
View File
@@ -248,3 +248,4 @@ dotnet/filtered-*.slnx
.omx/
**/issues/
.test_*
+17 -17
View File
@@ -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 Microsofts 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 Microsofts 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.
+4 -4
View File
@@ -173,11 +173,11 @@ new SampleDefinition
```csharp
new SampleDefinition
{
Name = "Workflow_Declarative_GenerateCode",
ProjectPath = "samples/03-workflows/Declarative/GenerateCode",
Name = "Workflow_Visualization",
ProjectPath = "samples/03-workflows/Visualization",
IsDeterministic = true,
MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"],
ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."],
MustContain = ["Generating workflow visualization...", "Mermaid string:", "DiGraph string:"],
ExpectedOutputDescription = ["The output should show workflow visualization in Mermaid and DiGraph formats."],
},
```
+6 -6
View File
@@ -22,14 +22,14 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.25" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.55.0" />
<PackageVersion Include="Azure.Core" Version="1.56.0" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
@@ -44,7 +44,7 @@
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
<PackageVersion Include="System.ClientModel" Version="1.12.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
@@ -109,7 +109,7 @@
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
+12 -7
View File
@@ -117,17 +117,18 @@
<Project Path="samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills/Agent_Step06_McpBasedSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Harness/">
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
@@ -173,6 +174,7 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
@@ -241,11 +243,10 @@
<Project Path="samples/03-workflows/Declarative/ExecuteCode/ExecuteCode.csproj" />
<Project Path="samples/03-workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/FunctionTools/FunctionTools.csproj" />
<Project Path="samples/03-workflows/Declarative/GenerateCode/GenerateCode.csproj" />
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
@@ -343,6 +344,9 @@
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
</Folder>
@@ -597,13 +601,14 @@
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
@@ -624,8 +629,8 @@
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
@@ -650,8 +655,8 @@
<Project Path="tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
+5 -1
View File
@@ -20,13 +20,17 @@
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
"src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj",
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
-3
View File
@@ -11,9 +11,6 @@
<ItemGroup Condition="'$(InjectSharedIntegrationTestAzureCredentialsCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\IntegrationTestsAzureCredentials\*.cs" LinkBase="Shared\IntegrationTestsAzureCredentials" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedBuildTestCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\CodeTests\*.cs" LinkBase="Shared\CodeTests" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedWorkflowsExecution)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Workflows\Execution\*.cs" LinkBase="Shared\Workflows" />
</ItemGroup>
@@ -363,6 +363,25 @@ internal static class AgentsSamples
],
},
new SampleDefinition
{
Name = "Agent_Step06_McpBasedSkills",
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
MustContain =
[
"Discovering MCP-based skills",
"Agent:",
],
ExpectedOutputDescription =
[
"The output should show the agent converting 26.2 miles to kilometers and 75 kilograms to pounds.",
"The response should contain approximate numeric values for both conversions.",
"The output should not contain error messages or stack traces.",
],
},
// ── AgentWithMemory ─────────────────────────────────────────────────
new SampleDefinition
@@ -439,15 +439,6 @@ internal static class WorkflowSamples
ExpectedOutputDescription = ["The output should show a workflow calling function tools (e.g. a menu plugin) to answer a question about restaurant specials."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_GenerateCode",
ProjectPath = "samples/03-workflows/Declarative/GenerateCode",
IsDeterministic = true,
MustContain = ["WORKFLOW: Parsing", "WORKFLOW: Defined"],
ExpectedOutputDescription = ["The output should show a YAML workflow being parsed and C# code being generated from it."],
},
new SampleDefinition
{
Name = "Workflow_Declarative_HostedWorkflow",
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.6.2</VersionPrefix>
<VersionPrefix>1.9.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260521</DateSuffix>
<DateSuffix>260603</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.6.2</GitTag>
<GitTag>1.9.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -16,6 +16,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -27,6 +27,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
app.UseHttpLogging();
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -17,6 +17,11 @@ builder.Services.AddAGUI();
// Configure to listen on port 8888
builder.WebHost.UseUrls("http://localhost:8888");
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to discover Agent Skills served over MCP.
//
// When launched with "--server", this executable runs a small MCP stdio server
// that exposes a unit-converter skill via the SEP-2640 convention:
// - skill://index.json — discovery document listing all skills
// - skill://unit-converter/SKILL.md — the skill instructions
//
// In default (client) mode the sample launches itself as a child process,
// connects via StdioClientTransport, and uses AgentSkillsProviderBuilder
// to discover and inject the skill into a ChatClientAgent.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
using OpenAI.Responses;
if (args.Length > 0 && args[0] == "--server")
{
await RunMcpServerAsync();
return;
}
// --- Configuration ---
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// --- MCP client + skill discovery ---
// Launch this same assembly as a stdio MCP server in a child process.
var thisAssemblyPath = typeof(Program).Assembly.Location;
Console.WriteLine("Discovering MCP-based skills");
await using McpClient client = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "skills-server",
Command = "dotnet",
Arguments = [thisAssemblyPath, "--server"],
}));
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(client)
.Build();
// --- Agent ---
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(new Uri(openAiEndpoint), new DefaultAzureCredential())
.GetResponsesClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "SkillsAgent",
ChatOptions = new()
{
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
},
AIContextProviders = [skillsProvider],
},
model: deploymentName);
// --- Run ---
Console.WriteLine(new string('-', 60));
AgentResponse response = await agent.RunAsync(
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
Console.WriteLine($"Agent: {response.Text}");
// --- Server mode (launched as a child process via --server) ---------------------------------
static async Task RunMcpServerAsync()
{
var builder = Host.CreateApplicationBuilder();
// Critical for stdio transport: any provider that writes to stdout will corrupt the
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
// appropriately.
builder.Logging.ClearProviders();
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddMcpServer(o => o.ServerInfo = new() { Name = "SkillsServer", Version = "1.0.0" })
.WithStdioServerTransport()
.WithResources<SkillResources>();
await builder.Build().RunAsync();
}
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerResourceType] attribute
[McpServerResourceType]
internal sealed class SkillResources
#pragma warning restore CA1812
{
private const string IndexJson = """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
"description": "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.",
"url": "skill://unit-converter/SKILL.md"
}
]
}
""";
private const string SkillMd = """
---
name: unit-converter
description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
---
## Usage
When the user requests a unit conversion, use these factors:
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
Formula: result = value × factor
""";
[McpServerResource(UriTemplate = "skill://index.json", Name = "Skill Index", MimeType = "application/json")]
[Description("SEP-2640 skill discovery index")]
public static string GetIndex() => IndexJson;
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "Unit Converter Skill", MimeType = "text/markdown")]
[Description("Unit converter skill instructions")]
public static string GetSkillMd() => SkillMd;
}
@@ -0,0 +1,34 @@
# MCP-Based Agent Skills Sample
This sample demonstrates how to discover **Agent Skills served over MCP** with a `ChatClientAgent`.
## What it demonstrates
- Hosting a small MCP server (in this same executable, launched with `--server`) that
exposes skill resources following the SEP-2640 convention.
- Connecting an `McpClient` to the embedded server via stdio transport.
- Building an `AgentSkillsProvider` via `UseMcpSkills(client)`, which reads
`skill://index.json` (SEP-2640 canonical discovery) and constructs skills from the
index entries.
- The progressive disclosure pattern across MCP: advertise → load → read resources, exactly
as for filesystem-backed skills.
## Running the Sample
### Prerequisites
- .NET 10.0 SDK
- Azure OpenAI endpoint with a deployed model
### Setup
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
```
### Run
```powershell
dotnet run
```
@@ -9,6 +9,7 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w
| [Agent_Step03_ClassBasedSkills](Agent_Step03_ClassBasedSkills/) | Define skills as C# classes using `AgentClassSkill`. |
| [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) | **(Advanced)** Combine file-based, code-defined, and class-based skills using `AgentSkillsProviderBuilder`. |
| [Agent_Step05_SkillsWithDI](Agent_Step05_SkillsWithDI/) | Use Dependency Injection with both code-defined (`AgentInlineSkill`) and class-based (`AgentClassSkill`) skills. |
| [Agent_Step06_McpBasedSkills](Agent_Step06_McpBasedSkills/) | Discover skills served over the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) via `AgentMcpSkillsSource`. Spins up an in-process MCP server that exposes skills as resources (`skill://...`) and connects an `McpClient` to it. |
## Key Concepts
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
// Foundry Toolbox MCP Skills.
//
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
// Toolbox endpoint and inject them as AIContextProviders so the agent can
// discover and use them at runtime.
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI;
using ModelContextProtocol.Client;
// --- Configuration ---
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
TokenCredential credential = new DefaultAzureCredential();
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
// --- Connect to the Foundry Toolbox MCP endpoint ---
await using McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// --- Discover MCP-based skills ---
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// --- Create the agent ---
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
AIAgent agent = aiProjectClient.AsAIAgent(
options: new ChatClientAgentOptions
{
Name = "ToolboxMcpSkillsAgent",
ChatOptions = new()
{
ModelId = deploymentName,
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
},
AIContextProviders = [skillsProvider],
});
// --- Interactive prompt ---
Console.Write("User: ");
string? query = Console.ReadLine();
if (string.IsNullOrWhiteSpace(query))
{
Console.WriteLine("No input provided.");
return;
}
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
// ---------------------------------------------------------------------------
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,32 @@
# Foundry Toolbox MCP Skills
This sample uses
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
## What this sample demonstrates
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
## Prerequisites
- A Microsoft Foundry project with a toolbox already configured
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
- Azure CLI installed and authenticated (`az login`)
Set the following environment variables:
```powershell
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
```
## Run the sample
```powershell
dotnet run
```
@@ -74,6 +74,7 @@ Some samples require extra tool-specific environment variables. See each sample
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
## Running the samples
@@ -72,6 +72,95 @@ public static class AnsiEscapes
/// </summary>
public static string ResetAttributes => "\x1b[0m";
/// <summary>
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
/// </summary>
/// <remarks>
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
/// combining characters, variation selectors, and East Asian wide characters may be
/// measured incorrectly. For the console harness this is acceptable since content is
/// predominantly ASCII, and emoji are padded with surrounding spaces.
/// </remarks>
public static int VisibleLength(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int length = 0;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
{
// Skip the ESC[ and all characters up to and including the final byte (0x400x7E).
i += 2;
while (i < text.Length && text[i] < 0x40)
{
i++;
}
// i now points to the final byte of the escape sequence; the for-loop will advance past it.
}
else if (text[i] != '\n' && text[i] != '\r')
{
length++;
}
}
return length;
}
/// <summary>
/// Counts the number of physical terminal rows a text item will occupy,
/// accounting for both explicit newlines and terminal line wrapping.
/// </summary>
/// <param name="text">The text to measure.</param>
/// <param name="terminalWidth">The terminal width in columns. If &lt;= 0, wrapping is ignored (1 row per logical line).</param>
/// <returns>The number of physical rows the text occupies.</returns>
public static int CountPhysicalLines(string text, int terminalWidth)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int physicalLines = 0;
int lineStart = 0;
for (int i = 0; i <= text.Length; i++)
{
if (i == text.Length || text[i] == '\n')
{
if (terminalWidth <= 0)
{
// No wrapping — each logical line is one physical row
physicalLines += 1;
}
else
{
string logicalLine = text[lineStart..i];
int visibleWidth = VisibleLength(logicalLine);
physicalLines += visibleWidth == 0
? 1
: (visibleWidth - 1) / terminalWidth + 1;
}
lineStart = i + 1;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
physicalLines--;
}
return physicalLines;
}
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
{
ConsoleColor.Black => 30,
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
{
/// <summary>
/// Calculates the height (in lines) needed to render all items.
/// Calculates the height (in lines) needed to render all items,
/// accounting for terminal line wrapping at the specified width.
/// </summary>
/// <param name="items">The items to measure.</param>
/// <returns>The total number of lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items)
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
/// <returns>The total number of physical lines all items will occupy.</returns>
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
{
int total = 0;
for (int i = 0; i < items.Count; i++)
{
total += CountLines(items[i]);
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
}
return total;
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
{
string text = props.Items[i];
string[] lines = text.Split('\n');
int lineCount = CountLines(text);
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
int itemRow = 0;
for (int j = 0; j < lineCount; j++)
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
{
int linePhysicalRows = props.Width > 0
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
: 1;
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
currentRow += linePhysicalRows;
itemRow += linePhysicalRows;
}
}
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
}
}
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
Console.Write(props.Items[i]);
}
// Calculate the offset from bottom for the start of the new last item
int lastItemLines = CountLines(props.Items[^1]);
// Calculate the offset from bottom for the start of the new last item,
// accounting for terminal line wrapping at the available width.
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
// Update rendered count
this._renderedCount = props.Items.Count;
}
private static int CountLines(string text)
{
if (string.IsNullOrEmpty(text))
{
return 0;
}
int count = 1;
for (int i = 0; i < text.Length; i++)
{
if (text[i] == '\n')
{
count++;
}
}
// If text ends with a newline, don't count the trailing empty line
if (text[text.Length - 1] == '\n')
{
count--;
}
return count;
}
}
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
/// </summary>
protected static object RenderLock { get; } = new();
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
where TProps : ConsoleReactiveProps
where TState : ConsoleReactiveState
{
private readonly object _renderLock = new();
private TProps? _lastRenderedProps;
private TState? _lastRenderedState;
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// </summary>
public override void Render()
{
lock (this._renderLock)
lock (RenderLock)
{
if (this.Props is null)
{
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
lock (RenderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
private BottomPanelMode _lastRenderedBottomPanelMode;
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
// Calculate queued items panel height
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
// Build the bottom panel child based on mode
ConsoleReactiveComponent bottomChild;
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
bottomChild = this._textInput;
}
// When the bottom panel mode changes, the new child must repaint even if its
// props haven't changed — the screen area was overwritten by the previous child.
if (state.Mode != this._lastRenderedBottomPanelMode)
{
bottomChild.Invalidate();
this._lastRenderedBottomPanelMode = state.Mode;
}
var ruleProps = new TopBottomRuleProps
{
Width = state.ConsoleWidth,
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
{
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
}
catch (JsonException ex)
catch (JsonException)
{
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
// JSON parsing failed — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
if (planningResponse is null)
{
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
// Null result — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
}
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
// Unexpected type — fall back to rendering as regular text output.
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
return null;
}
@@ -7,20 +7,20 @@ using Microsoft.Extensions.AI;
namespace Harness.Shared.Console.ToolFormatters;
/// <summary>
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
/// and structured output for complete/remove operations.
/// </summary>
public sealed class TodoToolFormatter : ToolCallFormatter
{
/// <inheritdoc/>
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
/// <inheritdoc/>
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
{
"TodoList_Add" => FormatAddTodos(call),
"TodoList_Complete" => FormatCompleteTodos(call),
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
"todos_add" => FormatAddTodos(call),
"todos_complete" => FormatCompleteTodos(call),
"todos_remove" => FormatIdList(call, "ids", "Remove"),
_ => null,
};
@@ -2,6 +2,7 @@
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
using System.Text.Json;
using Harness.Shared.Console.Observers;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
case StreamingResponseIncompleteUpdate incompleteUpdate:
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
{
string detail = GetContentFilterDetails(incompleteUpdate);
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
await ux.WriteInfoLineAsync(
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
ConsoleColor.Yellow);
}
else
{
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
}
break;
}
}
/// <summary>
/// Extracts content filter details from the serialized response JSON and returns
/// a formatted string showing which specific categories were triggered.
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
/// </summary>
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
{
try
{
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
using var doc = JsonDocument.Parse(data.ToString());
var root = doc.RootElement;
// Navigate into the nested response object if present.
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|| filtersArray.ValueKind != JsonValueKind.Array)
{
return string.Empty;
}
foreach (var filter in filtersArray.EnumerateArray())
{
if (!filter.TryGetProperty("content_filter_results", out var results)
|| results.ValueKind != JsonValueKind.Object)
{
continue;
}
// Collect category data for aligned output.
var categories = new List<(string Name, bool Filtered, string? Severity)>();
foreach (var category in results.EnumerateObject())
{
if (category.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
categories.Add((category.Name, filtered, severity));
}
// Build all category lines into a single string.
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
var lines = new List<string>();
foreach (var (name, filtered, severity) in categories)
{
string paddedName = name.PadRight(maxNameLen);
string icon = filtered ? "❌" : "✅";
string statusText = filtered ? "Filtered " : "Not Filtered";
string severityText = severity is not null ? $" Severity: {severity}" : "";
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
}
if (lines.Count > 0)
{
return string.Join("\n", lines);
}
}
return string.Empty;
}
catch
{
// Parsing not critical — skip silently if it fails.
return string.Empty;
}
}
}
@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
</Project>
@@ -1,105 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows.Declarative;
namespace Demo.DeclarativeEject;
/// <summary>
/// HOW TO: Convert a workflow from a declartive (yaml based) definition to code.
/// </summary>
/// <remarks>
/// <b>Usage</b>
/// Provide the path to the workflow definition file as the first argument.
/// All other arguments are intepreted as a queue of inputs.
/// When no input is queued, interactive input is requested from the console.
/// </remarks>
internal sealed class Program
{
public static void Main(string[] args)
{
Program program = new(args);
program.Execute();
}
private void Execute()
{
// Read and parse the declarative workflow.
Notify($"WORKFLOW: Parsing {Path.GetFullPath(this.WorkflowFile)}");
Stopwatch timer = Stopwatch.StartNew();
// Use DeclarativeWorkflowBuilder to generate code based on a YAML file.
string code =
DeclarativeWorkflowBuilder.Eject(
this.WorkflowFile,
DeclarativeWorkflowLanguage.CSharp,
workflowNamespace: "Demo.DeclarativeCode",
workflowPrefix: "Sample");
Notify($"\nWORKFLOW: Defined {timer.Elapsed}\n");
Console.WriteLine(code);
}
private const string DefaultWorkflow = "Marketing.yaml";
private string WorkflowFile { get; }
private Program(string[] args)
{
this.WorkflowFile = ParseWorkflowFile(args);
}
private static string ParseWorkflowFile(string[] args)
{
string workflowFile = args.FirstOrDefault() ?? DefaultWorkflow;
if (!File.Exists(workflowFile) && !Path.IsPathFullyQualified(workflowFile))
{
string? repoFolder = GetRepoFolder();
if (repoFolder is not null)
{
workflowFile = Path.Combine(repoFolder, "declarative-agents", "workflow-samples", workflowFile);
workflowFile = Path.ChangeExtension(workflowFile, ".yaml");
}
}
if (!File.Exists(workflowFile))
{
throw new InvalidOperationException($"Unable to locate workflow: {Path.GetFullPath(workflowFile)}.");
}
return workflowFile;
static string? GetRepoFolder()
{
DirectoryInfo? current = new(Directory.GetCurrentDirectory());
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
}
private static void Notify(string message)
{
Console.ForegroundColor = ConsoleColor.Cyan;
try
{
Console.WriteLine(message);
}
finally
{
Console.ResetColor();
}
}
}
@@ -1,28 +0,0 @@
{
"profiles": {
"Marketing": {
"commandName": "Project",
"commandLineArgs": "\"Marketing.yaml\""
},
"MathChat": {
"commandName": "Project",
"commandLineArgs": "\"MathChat.yaml\""
},
"Question": {
"commandName": "Project",
"commandLineArgs": "\"Question.yaml\""
},
"Research": {
"commandName": "Project",
"commandLineArgs": "\"DeepResearch.yaml\""
},
"ResponseObject": {
"commandName": "Project",
"commandLineArgs": "\"ResponseObject.yaml\""
},
"UserInput": {
"commandName": "Project",
"commandLineArgs": "\"UserInput.yaml\""
}
}
}
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
[SendsMessage(typeof(List<ChatMessage>))]
[SendsMessage(typeof(TurnToken))]
private sealed partial class ConcurrentStartExecutor()
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
{
[MessageHandler]
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
}
[MessageHandler]
@@ -63,13 +67,16 @@ internal static partial class WorkflowHelper
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
public ValueTask ResetAsync() => default;
}
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
[YieldsOutput(typeof(List<ChatMessage>))]
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
[YieldsOutput(typeof(string))]
private sealed partial class ConcurrentAggregationExecutor() :
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
{
private readonly List<ChatMessage> _messages = [];
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
public ValueTask ResetAsync()
{
this._messages.Clear();
return default;
}
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,12 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>",
"REDIS_CONNECTION_STRING": "localhost:6379",
"REDIS_STREAM_TTL_MINUTES": "10"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -1,8 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
}
}
@@ -1,10 +0,0 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -0,0 +1,6 @@
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
FOUNDRY_TOOLBOX_NAME=<your-toolbox-name>
AZURE_BEARER_TOKEN=DefaultAzureCredential
@@ -0,0 +1,26 @@
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
#
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
# which only succeeds when the project references its dependencies via PackageReference (see the
# commented-out section in HostedToolboxMcpSkills.csproj). Contributors building from the
# agent-framework repository source must use Dockerfile.contributor instead because
# ProjectReference dependencies live outside this folder and cannot be restored from inside
# this build context.
#
# Use the official .NET 10.0 ASP.NET runtime as a parent image
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
# Final stage
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
@@ -0,0 +1,18 @@
# Dockerfile for contributors building from the agent-framework repository source.
#
# This project uses ProjectReference to the local source, which means a standard
# multi-stage Docker build cannot resolve dependencies outside this folder.
# Pre-publish the app targeting the container runtime and copy the output:
#
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
# docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-toolbox-mcp-skills -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-toolbox-mcp-skills
#
# For end-users consuming the NuGet package (not ProjectReference), use the standard
# Dockerfile which performs a full dotnet restore + publish inside the container.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY out/ .
EXPOSE 8088
ENV ASPNETCORE_URLS=http://+:8088
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
<RootNamespace>HostedToolboxMcpSkills</RootNamespace>
<AssemblyName>HostedToolboxMcpSkills</AssemblyName>
<NoWarn>$(NoWarn);</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
<!-- For contributors: uses ProjectReference to build against local source -->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
</ItemGroup>
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.6.1-preview.260514.1" />
</ItemGroup>
-->
</Project>
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
// Hosted Toolbox MCP Skills Agent
//
// Demonstrates how to host an agent that discovers MCP-based skills from a
// Foundry Toolbox MCP endpoint and injects them as AIContextProviders using
// AgentSkillsProviderBuilder.UseMcpSkills().
//
// Required environment variables:
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5)
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Core;
using Azure.Identity;
using DotNetEnv;
using Hosted_Shared_Contributor_Setup;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry.Hosting;
using ModelContextProtocol.Client;
// Load .env file if present (for local development)
Env.TraversePath().Load();
var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5";
var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set.");
// Build the Toolbox MCP URL from the project endpoint and toolbox name.
var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1";
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
TokenCredential credential = new ChainedTokenCredential(
new DevTemporaryTokenCredential(),
new DefaultAzureCredential());
// ── Connect to the Foundry Toolbox MCP endpoint ─────────────────────────────
// Create an HttpClient that attaches a fresh Foundry bearer token to every request.
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") { CheckCertificateRevocationList = true });
Console.WriteLine($"Connecting to Foundry Toolbox '{toolboxName}' MCP server...");
await using var mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = toolboxName,
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
// ── Configure MCP-based skills provider ──────────────────────────────────────
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
// ── Create the agent ─────────────────────────────────────────────────────────
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
.AsAIAgent(new ChatClientAgentOptions
{
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-mcp-skills",
Description = "Hosted agent with MCP skills discovered from a Foundry Toolbox",
ChatOptions = new()
{
ModelId = deployment,
Instructions = "You are a helpful assistant.",
},
AIContextProviders = [skillsProvider],
});
// ── Build the host ───────────────────────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
var app = builder.Build();
app.MapFoundryResponses();
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
app.MapDevTemporaryLocalAgentEndpoint();
app.Run();
// ---------------------------------------------------------------------------
// HttpClientHandler: attaches a fresh Foundry bearer token to every request
// ---------------------------------------------------------------------------
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : HttpClientHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,103 @@
# Hosted-ToolboxMcpSkills
A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`.
The `AgentSkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider:
1. **Advertise** - skill names and descriptions are injected into the system prompt so the agent knows what is available.
2. **Load** - when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider.
3. **Read resources** - if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider.
This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`)
- A Foundry Toolbox already configured with skills provisioned
- Azure CLI logged in (`az login`)
## Configuration
Copy the template and fill in your values:
```bash
cp .env.example .env
```
Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name:
```env
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
ASPNETCORE_URLS=http://+:8088
ASPNETCORE_ENVIRONMENT=Development
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
FOUNDRY_TOOLBOX_NAME=my-toolbox
```
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
## Running directly (contributors)
This project uses `ProjectReference` to build against the local Agent Framework source.
```bash
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills
dotnet run
```
The agent will start on `http://localhost:8088`.
### Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What skills do you have available?"
```
## Running with Docker
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
### 1. Publish for the container runtime (Linux Alpine)
```bash
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
```
### 2. Build the Docker image
```bash
docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
```
### 3. Run the container
Generate a bearer token on your host and pass it to the container:
```bash
# Generate token (expires in ~1 hour)
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
# Run with token
docker run --rm -p 8088:8088 \
-e AGENT_NAME=hosted-toolbox-mcp-skills \
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
--env-file .env \
hosted-toolbox-mcp-skills
```
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
### 4. Test it
Using the Azure Developer CLI:
```bash
azd ai agent invoke --local "What skills do you have available?"
```
## NuGet package users
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
@@ -0,0 +1,43 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
name: hosted-toolbox-mcp-skills
displayName: "Hosted Toolbox MCP Skills Agent"
description: >
A hosted agent that discovers MCP-based skills from a Foundry Toolbox
and makes them available to the agent via the agent skills provider.
metadata:
tags:
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Agent Framework
- MCP
- Model Context Protocol
- Agent Skills
- Foundry Toolbox
- Foundry Toolbox Skills
template:
name: hosted-toolbox-mcp-skills
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: FOUNDRY_TOOLBOX_NAME
value: "{{FOUNDRY_TOOLBOX_NAME}}"
parameters:
properties:
- name: FOUNDRY_TOOLBOX_NAME
secret: false
description: Name of the Foundry Toolbox to connect to for MCP skill discovery
resources:
- kind: model
id: gpt-5
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,14 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: hosted-toolbox-mcp-skills
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
- name: FOUNDRY_TOOLBOX_NAME
value: ${FOUNDRY_TOOLBOX_NAME}
@@ -13,8 +13,10 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -13,8 +13,10 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>
@@ -101,6 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
@@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -19,6 +19,11 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
app.UseHttpLogging();
@@ -49,6 +49,11 @@ var agent = new AzureOpenAIClient(
AGUIServerSerializerContext.Default.Options)
]);
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
builder
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
@@ -12,6 +12,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUI();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
@@ -28,6 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -148,6 +152,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
app.MapOpenApi();
@@ -297,7 +297,7 @@ public class AgentResponse
AgentId = this.AgentId,
ResponseId = this.ResponseId,
MessageId = message.MessageId,
CreatedAt = this.CreatedAt,
CreatedAt = message.CreatedAt ?? this.CreatedAt,
};
}
@@ -7,7 +7,7 @@
## v1.0.0-preview.260219.1
- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays #3803
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
## v1.0.0-preview.260212.1
@@ -44,18 +44,33 @@ public static class HostedFoundryMemoryProviderScopes
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
/// only to the same user within the same conversation.
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, composing
/// <see cref="HostedSessionContext.UserId"/> and <see cref="HostedSessionContext.ChatId"/> into a
/// single delimiter-safe partition key. Use this when memories should be visible only to the same
/// user within the same conversation.
/// </summary>
/// <remarks>
/// Both identity values are opaque strings that may contain any characters, including the <c>:</c>
/// delimiter. To keep the composite key injective (so two distinct (user, chat) pairs can never
/// collide), each part is escaped (<c>\</c> becomes <c>\\</c>, then <c>:</c> becomes <c>\:</c>) before
/// being joined with a <c>::</c> separator.
/// </remarks>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
session =>
{
var ctx = GetRequiredHostedContext(session);
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
return new FoundryMemoryProvider.State(
new FoundryMemoryProviderScope($"{EscapeScopePart(ctx.UserId)}::{EscapeScopePart(ctx.ChatId)}"));
};
/// <summary>
/// Escapes special characters in a scope part so that distinct (user, chat) pairs produce distinct
/// composite scope keys. Backslashes are escaped first (<c>\</c> becomes <c>\\</c>), then colons
/// (<c>:</c> becomes <c>\:</c>), ensuring the <c>{user}::{chat}</c> format is unambiguous.
/// </summary>
private static string EscapeScopePart(string part) => part.Replace("\\", "\\\\").Replace(":", "\\:");
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
session?.GetHostedContext()
?? throw new InvalidOperationException(
@@ -281,14 +281,19 @@ internal static class OutputConverter
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
// Use the SDK's convenience method so the OutputItemFunctionToolCallOutput
// is constructed with a populated Id. The public OutputItemFunctionToolCallOutput
// ctor only sets CallId/Output (Id is read-only), and AddOutputItem<T>+EmitAdded
// does not auto-stamp Id — only ResponseId/AgentReference. Without this, the
// serialized item arrives at the Foundry storage layer with id=null and is
// rejected with "ID cannot be null or empty (Parameter 'id')".
foreach (var evt in stream.OutputItemFunctionCallOutput(
functionResult.CallId,
BinaryData.FromString(outputText));
BinaryData.FromString(outputText)))
{
yield return evt;
}
var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
yield return outputBuilder.EmitAdded(outputItem);
yield return outputBuilder.EmitDone(outputItem);
break;
}
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -13,7 +12,6 @@ using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Foundry;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
@@ -22,7 +20,6 @@ namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods for <see cref="AIProjectClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static partial class AIProjectClientExtensions
{
/// <summary>
@@ -374,6 +371,7 @@ public static partial class AIProjectClientExtensions
if (agentDefinition is DeclarativeAgentDefinition { Tools: { Count: > 0 } definitionTools })
{
// Check if no tools were provided while the agent definition requires in-proc tools.
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (requireInvocableTools && chatOptions?.Tools is not { Count: > 0 } && definitionTools.Any(t => t is FunctionTool))
{
throw new ArgumentException("The agent definition in-process tools must be provided in the extension method tools parameter.");
@@ -406,6 +404,7 @@ public static partial class AIProjectClientExtensions
(agentTools ??= []).Add(responseTool.AsAITool());
}
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (requireInvocableTools && missingTools is { Count: > 0 })
{
@@ -1,13 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry;
@@ -17,7 +15,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// <c>to_prompt_agent(agent)</c> function for agents whose underlying chat client is a
/// <see cref="FoundryChatClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class ChatClientAgentFoundryExtensions
{
/// <summary>
@@ -5,7 +5,6 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using OpenAI.Responses;
#pragma warning disable OPENAI001
@@ -27,7 +26,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// <c>FoundryAITool.CreateOpenApiTool(definition)</c>
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryAITool
{
/// <summary>
@@ -4,14 +4,12 @@ using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry;
@@ -36,7 +34,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// <c>AsAIAgent</c> extension methods on <see cref="AIProjectClient"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryAgent : DelegatingAIAgent
{
/// <summary>
@@ -261,6 +258,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
return innerAgent;
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (innerAgent.ChatClient.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
@@ -268,6 +266,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
ClientHeadersPolicy.Instance,
PipelinePosition.PerCall);
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return new ClientHeadersAgent(innerAgent);
}
@@ -2,13 +2,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Files;
using OpenAI.VectorStores;
@@ -23,7 +21,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// <see cref="FoundryChatClient"/> at the agent level so callers do not need to drop down to
/// <c>agent.GetService&lt;FoundryChatClient&gt;().X()</c> for common workflows.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryAgentExtensions
{
/// <summary>
@@ -4,7 +4,6 @@ using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
@@ -13,7 +12,6 @@ using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Files;
using OpenAI.Responses;
@@ -53,7 +51,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryChatClient : DelegatingChatClient
{
private readonly ChatClientMetadata _metadata;
@@ -652,6 +649,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
/// <summary>Best-effort registration of <see cref="AgentFrameworkUserAgentPolicy"/> via the MEAI <see cref="OpenAIRequestPolicies"/> hook with at-most-once dedup per pipeline.</summary>
private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient)
{
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
// OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against
@@ -663,6 +661,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
AgentFrameworkUserAgentPolicy.Instance,
PipelinePosition.PerCall);
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
/// <summary>
@@ -675,6 +674,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
/// </summary>
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
{
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
@@ -682,6 +682,7 @@ public sealed class FoundryChatClient : DelegatingChatClient
ServedModelPolicy.Instance,
PipelinePosition.PerCall);
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
@@ -1,14 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
@@ -34,7 +32,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// <item><description><b>Agent Endpoint (Mode 3)</b>: throw — no local definition exists to convert.</description></item>
/// </list>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal static class FoundryPromptAgentConverter
{
/// <summary>Performs the conversion for an agent whose chat client and chat options are supplied.</summary>
@@ -3,7 +3,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry;
@@ -22,7 +21,6 @@ namespace Microsoft.Agents.AI.Foundry;
/// <c>FoundryAITool.CreateHostedMcpToolbox(...)</c> factory overloads.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
{
/// <summary>
@@ -6,7 +6,6 @@
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
ships a stable 2.1.0. -->
<InjectSharedThrow>true</InjectSharedThrow>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -25,11 +24,13 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
<PackageReference Include="OpenAI" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
@@ -13,7 +13,6 @@ namespace Azure.AI.Extensions.OpenAI;
/// Provides extension methods for <see cref="ProjectResponsesClient"/>
/// to simplify the creation of AI agents that work with Azure AI services.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class ProjectResponsesClientExtensions
{
/// <summary>
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Extensions.AI;
@@ -32,11 +34,19 @@ public static class ChatClientHarnessExtensions
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// </param>
/// <param name="loggerFactory">
/// Optional logger factory for creating loggers used by the agent and its components.
/// </param>
/// <param name="services">
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
/// </param>
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
public static HarnessAgent AsHarnessAgent(
this IChatClient chatClient,
int maxContextWindowTokens,
int maxOutputTokens,
HarnessAgentOptions? options = null) =>
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
HarnessAgentOptions? options = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null) =>
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
}
@@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Compaction;
using Microsoft.Agents.AI.Tools.Shell;
#endif
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -105,6 +106,12 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// additional context providers, and chat history provider.
/// When <see langword="null"/>, the agent uses built-in default settings.
/// </param>
/// <param name="loggerFactory">
/// Optional logger factory for creating loggers used by the agent and its components.
/// </param>
/// <param name="services">
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
/// </param>
/// <exception cref="ArgumentNullException">
/// <paramref name="chatClient"/> is <see langword="null"/>.
/// </exception>
@@ -112,24 +119,26 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// <paramref name="maxContextWindowTokens"/> is not positive, or
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
/// </exception>
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
: base(BuildAgent(
Throw.IfNull(chatClient),
maxContextWindowTokens,
maxOutputTokens,
options))
options,
loggerFactory,
services))
{
}
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
{
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
AIAgentBuilder builder = innerAgent.AsBuilder();
if (options?.DisableToolApproval is not true)
{
builder.UseToolApproval();
builder.UseToolApproval(options?.ToolApprovalAgentOptions);
}
if (options?.DisableOpenTelemetry is not true)
@@ -137,10 +146,10 @@ public sealed class HarnessAgent : DelegatingAIAgent
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
}
return builder.Build();
return builder.Build(services);
}
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
{
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: maxContextWindowTokens,
@@ -165,13 +174,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
var compactionProvider = new CompactionProvider(compactionStrategy);
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
return chatClient
.AsBuilder()
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
.UseMessageInjection()
@@ -189,7 +198,9 @@ public sealed class HarnessAgent : DelegatingAIAgent
RequirePerServiceCallChatHistoryPersistence = true,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
});
},
loggerFactory,
services);
}
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
@@ -215,7 +226,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
return result;
}
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options, ILoggerFactory? loggerFactory)
{
var providers = new List<AIContextProvider>();
@@ -255,8 +266,8 @@ public sealed class HarnessAgent : DelegatingAIAgent
if (options?.DisableAgentSkillsProvider is not true)
{
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
? new AgentSkillsProvider(source)
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
? new AgentSkillsProvider(source, loggerFactory: loggerFactory)
: new AgentSkillsProvider(Directory.GetCurrentDirectory(), loggerFactory: loggerFactory);
providers.Add(skillsProvider);
}
@@ -101,6 +101,15 @@ public sealed class HarnessAgentOptions
/// </remarks>
public bool DisableToolApproval { get; set; }
/// <summary>
/// Gets or sets the options for the <see cref="ToolApprovalAgent"/> middleware.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the <see cref="ToolApprovalAgent"/> uses default settings.
/// This property has no effect when <see cref="DisableToolApproval"/> is <see langword="true"/>.
/// </remarks>
public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>

Some files were not shown because too many files have changed in this diff Show More