diff --git a/.github/actions/free-runner-disk-space/action.yml b/.github/actions/free-runner-disk-space/action.yml
new file mode 100644
index 0000000000..c534b1c2d8
--- /dev/null
+++ b/.github/actions/free-runner-disk-space/action.yml
@@ -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
diff --git a/.github/scripts/pr_limit_moderation.js b/.github/scripts/pr_limit_moderation.js
new file mode 100644
index 0000000000..6cb23e053a
--- /dev/null
+++ b/.github/scripts/pr_limit_moderation.js
@@ -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,
+};
diff --git a/.github/tests/test_pr_limit_moderation.js b/.github/tests/test_pr_limit_moderation.js
new file mode 100644
index 0000000000..5f0c8c865a
--- /dev/null
+++ b/.github/tests/test_pr_limit_moderation.js
@@ -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);
+ });
+});
diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml
index 8fe1fbf176..6582240a5c 100644
--- a/.github/workflows/dotnet-build-and-test.yml
+++ b/.github/workflows/dotnet-build-and-test.yml
@@ -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:
diff --git a/.github/workflows/limit-community-prs.yml b/.github/workflows/limit-community-prs.yml
new file mode 100644
index 0000000000..2fe66ccd20
--- /dev/null
+++ b/.github/workflows/limit-community-prs.yml
@@ -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,
+ });
diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml
index 0e59e4254f..3f6a66fd4d 100644
--- a/.github/workflows/markdown-link-check.yml
+++ b/.github/workflows/markdown-link-check.yml
@@ -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
diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml
index 3073a71636..cc055ce5a7 100644
--- a/.github/workflows/python-integration-tests.yml
+++ b/.github/workflows/python-integration-tests.yml
@@ -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
diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml
index 919c320c08..b1b1a8627b 100644
--- a/.github/workflows/python-merge-tests.yml
+++ b/.github/workflows/python-merge-tests.yml
@@ -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
diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml
index f03967e72a..5637b5a2fb 100644
--- a/.github/workflows/python-test-coverage-report.yml
+++ b/.github/workflows/python-test-coverage-report.yml
@@ -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"
diff --git a/.gitignore b/.gitignore
index 07eee848e2..9cb714813a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -248,3 +248,4 @@ dotnet/filtered-*.slnx
.omx/
**/issues/
+.test_*
diff --git a/SUPPORT.md b/SUPPORT.md
index a95ac5c597..1ed5d443f4 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -1,17 +1,17 @@
-# Support
-
-## How to file issues and get help
-
-This project uses GitHub Issues to track bugs and feature requests. Please search the existing
-issues before filing new issues to avoid duplicates. For new issues, file your bug or
-feature request as a new Issue.
-
-For help and questions about using this project, please create a GitHub issue.
-
-AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance
-
-For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels.
-
-## Microsoft Support Policy
-
-Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
+# Support
+
+## How to file issues and get help
+
+This project uses GitHub Issues to track bugs and feature requests. Please search the existing
+issues before filing new issues to avoid duplicates. For new issues, file your bug or
+feature request as a new Issue.
+
+For help and questions about using this project, please create a GitHub issue.
+
+AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance
+
+For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels.
+
+## Microsoft Support Policy
+
+Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 23c54c356b..03ae6e73b4 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -22,14 +22,14 @@
-
-
-
+
+
+
-
+
@@ -44,7 +44,7 @@
-
+
@@ -109,7 +109,7 @@
-
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index e2ae05c3b4..e395627bc9 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -174,6 +174,7 @@
+
@@ -343,6 +344,9 @@
+
+
+
@@ -604,6 +608,7 @@
+
diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf
index cc84fe6c5a..f4bf930e45 100644
--- a/dotnet/agent-framework-release.slnf
+++ b/dotnet/agent-framework-release.slnf
@@ -20,14 +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",
diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props
index 38f0c94c7d..28bc03d112 100644
--- a/dotnet/nuget/nuget-package.props
+++ b/dotnet/nuget/nuget-package.props
@@ -1,14 +1,14 @@
- 1.7.0
+ 1.9.01
- 260526
+ 260603$(VersionPrefix)-rc$(RCNumber)$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1$(VersionPrefix)-preview.$(DateSuffix).1$(VersionPrefix)
- 1.7.0
+ 1.9.0Debug;Release;Publishtrue
diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs
index 2c7333015d..0981ece789 100644
--- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs
+++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Program.cs
@@ -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"]
diff --git a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Server.csproj
index 01c8663a7b..a551fed512 100644
--- a/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Server.csproj
+++ b/dotnet/samples/02-agents/AGUI/Step01_GettingStarted/Server/Server.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs
index 33a32410e2..53b680c861 100644
--- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs
+++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Program.cs
@@ -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"]
diff --git a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Server.csproj
index 01c8663a7b..a551fed512 100644
--- a/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Server.csproj
+++ b/dotnet/samples/02-agents/AGUI/Step02_BackendTools/Server/Server.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs
index 2c7333015d..0981ece789 100644
--- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs
+++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Program.cs
@@ -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"]
diff --git a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Server.csproj
index 01c8663a7b..a551fed512 100644
--- a/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Server.csproj
+++ b/dotnet/samples/02-agents/AGUI/Step03_FrontendTools/Server/Server.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs
index edfcd03219..88967acb99 100644
--- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs
+++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Program.cs
@@ -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();
diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Server.csproj
index 01c8663a7b..a551fed512 100644
--- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Server.csproj
+++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/Server.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs
index 1965cf55f7..67a6889fb1 100644
--- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs
+++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Program.cs
@@ -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"]
diff --git a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Server.csproj b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Server.csproj
index 01c8663a7b..a551fed512 100644
--- a/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Server.csproj
+++ b/dotnet/samples/02-agents/AGUI/Step05_StateManagement/Server/Server.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj
new file mode 100644
index 0000000000..5243b24af0
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs
new file mode 100644
index 0000000000..6efb8ce40e
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Program.cs
@@ -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
+ {
+ ["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 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);
+ }
+}
diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md
new file mode 100644
index 0000000000..2e8efef8cc
--- /dev/null
+++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/README.md
@@ -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
+```
diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md
index 3e203c1fc6..dda1e476f0 100644
--- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md
+++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md
@@ -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
diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs
index cf916938e7..6e5d51380d 100644
--- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs
+++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs
@@ -72,6 +72,95 @@ public static class AnsiEscapes
///
public static string ResetAttributes => "\x1b[0m";
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ 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 (0x40–0x7E).
+ 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;
+ }
+
+ ///
+ /// Counts the number of physical terminal rows a text item will occupy,
+ /// accounting for both explicit newlines and terminal line wrapping.
+ ///
+ /// The text to measure.
+ /// The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).
+ /// The number of physical rows the text occupies.
+ 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,
diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs
index 5692b58266..d63c243108 100644
--- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs
+++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
public class TextPanel : ConsoleReactiveComponent
{
///
- /// 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.
///
/// The items to measure.
- /// The total number of lines all items will occupy.
- public static int CalculateHeight(IReadOnlyList items)
+ /// The terminal width in columns. When 0 or negative, wrapping is ignored.
+ /// The total number of physical lines all items will occupy.
+ public static int CalculateHeight(IReadOnlyList 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 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 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;
- }
}
diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs
index d71436e5e6..fa923efdf8 100644
--- a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs
+++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
{
}
+ ///
+ /// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
+ ///
+ protected static object RenderLock { get; } = new();
+
///
/// Gets or sets the component's props as the base type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent : 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 : ConsoleReactive
///
public override void Render()
{
- lock (this._renderLock)
+ lock (RenderLock)
{
if (this.Props is null)
{
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent : ConsoleReactive
///
public override void Invalidate()
{
- lock (this._renderLock)
+ lock (RenderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs
index d100c9d081..b2104aa5fd 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent
/// Initializes a new instance of the class.
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent(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 { 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;
}
diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs
index 9db00c9a65..8cf5abee57 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs
+++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs
@@ -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;
}
}
+
+ ///
+ /// Extracts content filter details from the serialized response JSON and returns
+ /// a formatted string showing which specific categories were triggered.
+ /// Returns if details cannot be extracted.
+ ///
+ 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();
+
+ 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;
+ }
+ }
}
diff --git a/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs
index 54e3eb40f2..4db325b658 100644
--- a/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs
+++ b/dotnet/samples/03-workflows/Observability/WorkflowAsAnAgent/WorkflowHelper.cs
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
///
/// Executor that starts the concurrent processing by sending messages to the agents.
///
- private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
+ [SendsMessage(typeof(List))]
+ [SendsMessage(typeof(TurnToken))]
+ private sealed partial class ConcurrentStartExecutor()
+ : Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
{
[MessageHandler]
- internal ValueTask RouteMessages(List messages, IWorkflowContext context, CancellationToken cancellationToken)
+ internal ValueTask RouteMessages(IEnumerable messages, IWorkflowContext context, CancellationToken cancellationToken)
{
- return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
+ List payload = messages as List ?? 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;
}
///
/// Executor that aggregates the results from the concurrent agents.
///
- [YieldsOutput(typeof(List))]
- private sealed partial class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor")
+ [YieldsOutput(typeof(string))]
+ private sealed partial class ConcurrentAggregationExecutor() :
+ Executor>("ConcurrentAggregationExecutor"), IResettableExecutor
{
private readonly List _messages = [];
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
+
+ public ValueTask ResetAsync()
+ {
+ this._messages.Clear();
+ return default;
+ }
}
}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/01_SingleAgent/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/02_AgentOrchestration_Chaining/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/03_AgentOrchestration_Concurrency/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/04_AgentOrchestration_Conditionals/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/05_AgentOrchestration_HITL/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/07_AgentAsMcpTool/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/local.settings.json b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/local.settings.json
deleted file mode 100644
index 71e7ff8dac..0000000000
--- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/08_ReliableStreaming/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": "",
- "REDIS_CONNECTION_STRING": "localhost:6379",
- "REDIS_STREAM_TTL_MINUTES": "10"
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json
deleted file mode 100644
index fcb6658e92..0000000000
--- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/04_WorkflowMcpTool/local.settings.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json
deleted file mode 100644
index 5f6d7d3340..0000000000
--- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/05_WorkflowAndAgents/local.settings.json
+++ /dev/null
@@ -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_DEPLOYMENT_NAME": ""
- }
-}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
index a421bd634d..7801e43532 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example
new file mode 100644
index 0000000000..5c312b3f8e
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/.env.example
@@ -0,0 +1,6 @@
+AZURE_AI_PROJECT_ENDPOINT=
+ASPNETCORE_URLS=http://+:8088
+ASPNETCORE_ENVIRONMENT=Development
+AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
+FOUNDRY_TOOLBOX_NAME=
+AZURE_BEARER_TOKEN=DefaultAzureCredential
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile
new file mode 100644
index 0000000000..d04bf72711
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile
@@ -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"]
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor
new file mode 100644
index 0000000000..a01cc1e38c
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Dockerfile.contributor
@@ -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"]
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj
new file mode 100644
index 0000000000..d4c4155baf
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ HostedToolboxMcpSkills
+ HostedToolboxMcpSkills
+ $(NoWarn);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs
new file mode 100644
index 0000000000..f8ca3f4991
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/Program.cs
@@ -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
+ {
+ ["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 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);
+ }
+}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md
new file mode 100644
index 0000000000..0c6b5ba60e
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md
@@ -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://.services.ai.azure.com/api/projects/
+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.
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml
new file mode 100644
index 0000000000..2887336252
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.manifest.yaml
@@ -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
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml
new file mode 100644
index 0000000000..5f53abb2e2
--- /dev/null
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/agent.yaml
@@ -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}
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj
index 954036ba3b..995de85710 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj
@@ -13,8 +13,10 @@
+
+
diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj
index 3c739b96d0..5e6d95dbf6 100644
--- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj
+++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj
@@ -13,8 +13,10 @@
+
+
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
index c12a1c9431..c694351599 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
@@ -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();
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj
index 96a72d1109..03e2493623 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj
@@ -15,6 +15,7 @@
+
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
index e3b0020362..3f0032d4da 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/Program.cs
@@ -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();
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
index a12ca1c5ad..575924255a 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
@@ -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
diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj
index e798d23506..8d44079173 100644
--- a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj
+++ b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/AGUIWebChatServer.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs
index 185b7d6bbf..06a138b8c3 100644
--- a/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIWebChat/Server/Program.cs
@@ -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.");
diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
index 61cfdcdb68..ca399272b4 100644
--- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
+++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -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("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();
diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/keycloak/setup-redirect-uris.sh
old mode 100755
new mode 100644
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs
index 081e054efc..13680e8bcb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs
@@ -297,7 +297,7 @@ public class AgentResponse
AgentId = this.AgentId,
ResponseId = this.ResponseId,
MessageId = message.MessageId,
- CreatedAt = this.CreatedAt,
+ CreatedAt = message.CreatedAt ?? this.CreatedAt,
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
index fe16edeb3b..1006f0fdb4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
@@ -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+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(itemId);
- yield return outputBuilder.EmitAdded(outputItem);
- yield return outputBuilder.EmitDone(outputItem);
break;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
index 413a8a397f..d2c529e317 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj
@@ -24,11 +24,13 @@
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/ChatClientHarnessExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Harness/ChatClientHarnessExtensions.cs
index 1a55624f3d..be66e9e635 100644
--- a/dotnet/src/Microsoft.Agents.AI.Harness/ChatClientHarnessExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Harness/ChatClientHarnessExtensions.cs
@@ -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 , the agent uses built-in default settings.
///
+ ///
+ /// Optional logger factory for creating loggers used by the agent and its components.
+ ///
+ ///
+ /// Optional service provider for resolving dependencies required by AI functions and other agent components.
+ ///
/// A new instance.
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);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs
index ef1af05513..139f47db3c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Harness/HarnessAgent.cs
@@ -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 , the agent uses built-in default settings.
///
+ ///
+ /// Optional logger factory for creating loggers used by the agent and its components.
+ ///
+ ///
+ /// Optional service provider for resolving dependencies required by AI functions and other agent components.
+ ///
///
/// is .
///
@@ -112,18 +119,20 @@ public sealed class HarnessAgent : DelegatingAIAgent
/// is not positive, or
/// is negative or greater than or equal to .
///
- 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();
@@ -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 contextProviders = BuildContextProviders(options);
+ IEnumerable 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 BuildContextProviders(HarnessAgentOptions? options)
+ private static List BuildContextProviders(HarnessAgentOptions? options, ILoggerFactory? loggerFactory)
{
var providers = new List();
@@ -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);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
index 200aa29ccc..b91ea40baa 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
@@ -27,6 +27,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
index 29ab28c250..cb0e15dac8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
@@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions
/// The agent builder whose name identifies the agent.
/// An optional callback to configure .
/// The for chaining.
+ ///
+ ///
+ /// Trust model. The A2A contextId arrives from the wire
+ /// and is treated as a chain-resume identifier — not as an authorization
+ /// token. The contract carries no principal/owner
+ /// dimension, so when a persistent store is registered any caller who knows or
+ /// guesses another caller's contextId can resume that other caller's
+ /// persisted thread. Hosts that serve more than one user must compose a principal
+ /// dimension into the lookup key — typically by calling
+ /// UseClaimsBasedSessionIsolation(...) from
+ /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom
+ /// ). When no isolation provider is
+ /// registered, behavior is unchanged — the bare contextId is used as the
+ /// conversation identifier, which is appropriate for first-run / single-user /
+ /// prototyping scenarios but unsafe for multi-user hosts.
+ ///
+ ///
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
@@ -46,6 +63,13 @@ public static class A2AServerServiceCollectionExtensions
/// The name of the agent to create an A2A server for.
/// An optional callback to configure .
/// The for chaining.
+ ///
+ /// See the trust-model remarks on
+ /// for guidance on multi-user hosts (the wire contextId is a chain-resume
+ /// identifier, not an authorization token; multi-user hosts must compose a
+ /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom
+ /// ).
+ ///
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -65,6 +89,13 @@ public static class A2AServerServiceCollectionExtensions
/// The agent instance to create an A2A server for.
/// An optional callback to configure .
/// The for chaining.
+ ///
+ /// See the trust-model remarks on
+ /// for guidance on multi-user hosts (the wire contextId is a chain-resume
+ /// identifier, not an authorization token; multi-user hosts must compose a
+ /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom
+ /// ).
+ ///
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -83,6 +114,13 @@ public static class A2AServerServiceCollectionExtensions
/// The name of the agent to create an A2A server for.
/// An optional callback to configure .
/// The for chaining.
+ ///
+ /// See the trust-model remarks on
+ /// for guidance on multi-user hosts (the wire contextId is a chain-resume
+ /// identifier, not an authorization token; multi-user hosts must compose a
+ /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom
+ /// ).
+ ///
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
@@ -114,6 +152,13 @@ public static class A2AServerServiceCollectionExtensions
/// The agent instance to create an A2A server for.
/// An optional callback to configure .
/// The for chaining.
+ ///
+ /// See the trust-model remarks on
+ /// for guidance on multi-user hosts (the wire contextId is a chain-resume
+ /// identifier, not an authorization token; multi-user hosts must compose a
+ /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom
+ /// ).
+ ///
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
@@ -140,9 +185,17 @@ public static class A2AServerServiceCollectionExtensions
var agentSessionStore = serviceProvider.GetKeyedService(agent.Name);
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
+ // Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
+ var isolationKeyProvider = serviceProvider.GetService();
+ if (agentSessionStore?.GetService() is null)
+ {
+ agentSessionStore ??= new InMemoryAgentSessionStore();
+ agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
+ }
+
var hostAgent = new AIHostAgent(
innerAgent: agent,
- sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
+ sessionStore: agentSessionStore);
agentHandler = new A2AAgentHandler(hostAgent, runMode);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
index 948ecdca42..0d4c390bbb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
@@ -73,6 +73,26 @@ public static class AGUIEndpointRouteBuilderExtensions
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
///
+ ///
+ /// Trust model. The AG-UI RunAgentInput.ThreadId arrives
+ /// from the wire and is treated as a chain-resume identifier — not as an
+ /// authorization token. The contract carries no
+ /// principal/owner dimension, so when a persistent store is registered any caller
+ /// who knows or guesses another caller's ThreadId can resume that other
+ /// caller's persisted thread. Hosts that serve more than one user must compose a
+ /// principal dimension into the lookup key. The recommended way is to wrap the
+ /// keyed in
+ /// , typically by calling
+ /// UseClaimsBasedSessionIsolation(...) from
+ /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom
+ /// ) and registering the store via the
+ /// WithSessionStore(...) / WithInMemorySessionStore(...) helpers on
+ /// so that the wrapper is applied. When no
+ /// isolation provider is registered, behavior is unchanged — the bare
+ /// ThreadId is used as the conversation identifier, which is appropriate
+ /// for first-run / single-user / prototyping scenarios but unsafe for
+ /// multi-user hosts.
+ ///
///
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
@@ -83,7 +103,16 @@ public static class AGUIEndpointRouteBuilderExtensions
ArgumentNullException.ThrowIfNull(aiAgent);
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name);
- var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
+
+ // Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
+ var isolationKeyProvider = endpoints.ServiceProvider.GetService();
+ if (agentSessionStore?.GetService() is null)
+ {
+ agentSessionStore ??= new NoopAgentSessionStore();
+ agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
+ }
+
+ var hostAgent = new AIHostAgent(aiAgent, agentSessionStore);
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs
new file mode 100644
index 0000000000..3d61b9bea1
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// A that extracts the session isolation key from a claim
+/// in the current user's identity, as provided by ASP.NET Core's .
+///
+///
+///
+/// This provider is suitable for ASP.NET Core web applications where session isolation is based on
+/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
+/// from the ambient .
+///
+///
+/// If the is unavailable, the user is not authenticated, or the specified claim
+/// is missing, the provider returns . The consuming
+/// will then enforce strict or pass-through behavior based on its configuration.
+///
+///
+/// This class relies on , which uses
+/// to provide access to the current .
+///
+///
+public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
+{
+ private readonly IHttpContextAccessor? _httpContextAccessor;
+ private readonly string _claimType;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The used to retrieve the current HTTP context and user claims.
+ ///
+ /// The options for configuring the provider. If null, defaults are used.
+ ///
+ /// is null, empty, or whitespace.
+ ///
+ public ClaimsIdentitySessionIsolationKeyProvider(
+ IHttpContextAccessor? httpContextAccessor,
+ ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
+ {
+ options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
+ this._httpContextAccessor = httpContextAccessor;
+ this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
+ }
+
+ ///
+ /// Extracts the session isolation key from the current user's claims.
+ ///
+ /// The to monitor for cancellation requests.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains the value of the
+ /// configured claim type from the current user's identity, or if the claim
+ /// is not present or the HTTP context is unavailable.
+ ///
+ ///
+ /// This method retrieves the claim value from HttpContext.User.Claims. If multiple claims
+ /// of the specified type exist, the first match is returned.
+ ///
+ public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
+ {
+ Claim? claim = this._httpContextAccessor?
+ .HttpContext?
+ .User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
+
+ return new ValueTask(claim?.Value);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs
new file mode 100644
index 0000000000..13845bc680
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Security.Claims;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Options for configuring .
+///
+public class ClaimsIdentitySessionIsolationKeyProviderOptions
+{
+ ///
+ /// Gets or sets the claim type to extract from the user's identity for session isolation.
+ ///
+ ///
+ ///
+ /// Defaults to , which typically corresponds to
+ /// the user's name or unique identifier claim.
+ ///
+ ///
+ /// Common alternatives include:
+ ///
+ /// ClaimTypes.NameIdentifier — Stable user identifier
+ /// ClaimTypes.Email — Email address
+ /// Custom claim types specific to your authentication provider
+ ///
+ ///
+ ///
+ public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj
new file mode 100644
index 0000000000..2dd1834008
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj
@@ -0,0 +1,30 @@
+
+
+
+ $(TargetFrameworksCore)
+ Microsoft.Agents.AI.Hosting.AspNetCore
+ preview
+ $(NoWarn)
+
+
+
+
+
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+ Microsoft Agent Framework Hosting ASP.NET Core
+ Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..0ff8d37371
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Extension methods for configuring AI hosting services in an .
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers a that uses claims from the current user's identity
+ /// to generate session isolation keys.
+ ///
+ /// The to add services to.
+ /// Optional configuration for the claims-based session isolation key provider.
+ /// The so that additional calls can be chained.
+ ///
+ /// This method requires to be registered in the service collection.
+ /// Ensure that services.AddHttpContextAccessor() has been called before using this method.
+ ///
+ public static IServiceCollection UseClaimsBasedSessionIsolation(
+ this IServiceCollection services,
+ ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
+ {
+ options ??= new();
+ ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
+ services.Add(descriptor);
+
+ return services;
+
+ object CreateIsolationKeyProvider(IServiceProvider serviceProvider)
+ {
+ IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService();
+
+ return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs
index 2f57e26409..7c0539fe51 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
@@ -9,9 +11,39 @@ namespace Microsoft.Agents.AI.Hosting;
/// Defines the contract for storing and retrieving agent conversation threads.
///
///
+///
/// Implementations of this interface enable persistent storage of conversation threads,
/// allowing conversations to be resumed across HTTP requests, application restarts,
/// or different service instances in hosted scenarios.
+///
+///
+/// Trust model. The conversationId passed to
+/// and typically originates
+/// from the wire (for example, an AG-UI RunAgentInput.ThreadId or an A2A
+/// contextId). It is a chain-resume identifier, not an authorization
+/// token, and the (agent, conversationId) tuple carries no principal/owner
+/// dimension. Hosts that serve more than one user from the same registered store must
+/// therefore compose a principal dimension into the lookup key, otherwise any caller
+/// who knows or guesses another caller's conversationId can resume
+/// that other caller's persisted thread. The framework provides
+/// as a decorator that rewrites
+/// conversationId to include an isolation key resolved from a
+/// (for example, the ASP.NET Core
+/// ClaimsIdentitySessionIsolationKeyProvider wired up via
+/// UseClaimsBasedSessionIsolation(...)). When no provider is registered, the
+/// store behaves as a single-namespace persistence layer — appropriate for
+/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts.
+///
+///
+/// Implementer guidance. Implementations should treat
+/// conversationId as opaque: do not parse it, do not impose length
+/// or character-set constraints on it, and do not assume it round-trips to the value
+/// the caller originally supplied (decorators such as
+/// may rewrite it before forwarding).
+/// Be aware that any logging, telemetry, or audit sink that surfaces
+/// conversationId will also surface the isolation prefix when a
+/// scoping decorator is in the chain.
+///
///
public abstract class AgentSessionStore
{
@@ -43,4 +75,35 @@ public abstract class AgentSessionStore
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default);
+
+ /// Asks the for an object of the specified type .
+ /// The type of object being requested.
+ /// An optional key that can be used to help identify the target service.
+ /// The found object, otherwise .
+ /// is .
+ ///
+ /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the ,
+ /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
+ /// to verify that specific store implementations are present.
+ ///
+ public virtual object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ _ = Throw.IfNull(serviceType);
+
+ return serviceKey is null && serviceType.IsInstanceOfType(this)
+ ? this
+ : null;
+ }
+
+ /// Asks the for an object of type .
+ /// The type of the object to be retrieved.
+ /// An optional key that can be used to help identify the target service.
+ /// The found object, otherwise .
+ ///
+ /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the ,
+ /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains
+ /// to verify that specific store implementations are present.
+ ///
+ public TService? GetService(object? serviceKey = null)
+ => this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs
new file mode 100644
index 0000000000..e80d3b907a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides an abstract base class for agent session stores that delegate operations to an inner store
+/// instance while allowing for extensibility and customization.
+///
+///
+///
+/// implements the decorator pattern for s,
+/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
+/// underlying store.
+///
+///
+/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store.
+/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store
+/// interface.
+///
+///
+public abstract class DelegatingAgentSessionStore : AgentSessionStore
+{
+ ///
+ /// Initializes a new instance of the class with the specified inner
+ /// store.
+ ///
+ /// The underlying session store instance that will handle the core operations.
+ /// is .
+ ///
+ /// The inner session store serves as the foundation of the delegation chain. All operations not overridden by
+ /// derived classes will be forwarded to this store.
+ ///
+ protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
+ {
+ this.InnerStore = Throw.IfNull(innerStore);
+ }
+
+ ///
+ /// Gets the inner session store instance that receives delegated operations.
+ ///
+ ///
+ /// The underlying instance that handles core storage operations.
+ ///
+ ///
+ /// Derived classes can use this property to access the inner session store for custom delegation scenarios
+ /// or to forward operations with additional processing.
+ ///
+ protected AgentSessionStore InnerStore { get; }
+
+ ///
+ public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
+ => this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
+
+ ///
+ public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
+ => this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
+
+ ///
+ ///
+ /// This implementation first checks if this instance satisfies the service request.
+ /// If not, it chains the request to the inner store, allowing services to be retrieved
+ /// from any store in the delegation chain.
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ // First, check if this instance satisfies the request
+ object? service = base.GetService(serviceType, serviceKey);
+ if (service is not null)
+ {
+ return service;
+ }
+
+ // Chain to the inner store
+ return this.InnerStore.GetService(serviceType, serviceKey);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs
index d1397fcda4..ed11840f5e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs
@@ -3,6 +3,7 @@
using System;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
@@ -16,12 +17,11 @@ public static class HostedAgentBuilderExtensions
/// Configures the host agent builder to use an in-memory session store for agent session management.
///
/// The host agent builder to configure with the in-memory session store.
+ /// When , wraps the session store with an
+ /// to provide isolation-key-based scoping for sessions. Defaults to .
/// The same instance, configured to use an in-memory session store.
- public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
- {
- builder.ServiceCollection.AddKeyedSingleton(builder.Name, new InMemoryAgentSessionStore());
- return builder;
- }
+ public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
+ => builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
///
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
@@ -29,12 +29,11 @@ public static class HostedAgentBuilderExtensions
///
/// The host agent builder to configure with the session store. Cannot be null.
/// The agent session store instance to register. Cannot be null.
+ /// When , wraps the session store with an
+ /// to provide isolation-key-based scoping for sessions. Defaults to .
/// The same host agent builder instance, allowing for method chaining.
- public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
- {
- builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
- return builder;
- }
+ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
+ => builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
///
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
@@ -44,16 +43,36 @@ public static class HostedAgentBuilderExtensions
/// name.
/// The DI service lifetime for the session store registration. Defaults to
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.
+ /// When , wraps the session store with an
+ /// to provide isolation-key-based scoping for sessions. Defaults to .
/// The same host agent builder instance, enabling further configuration.
- public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true)
{
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
{
Throw.IfNull(key);
var keyString = key as string;
Throw.IfNullOrEmpty(keyString);
- return createAgentSessionStore(sp, keyString) ??
+
+ AgentSessionStore store = createAgentSessionStore(sp, keyString) ??
throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'.");
+
+ if (withIsolation && store.GetService() is null)
+ {
+ var isolationKeyProvider = sp.GetService();
+
+ // Best efforts options getting
+ IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService();
+ if (options is null)
+ {
+ var optionsProvider = sp.GetService>();
+ options = optionsProvider?.Value;
+ }
+
+ store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
+ }
+
+ return store;
}, lifetime);
return builder;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs
new file mode 100644
index 0000000000..f379d625fd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs
@@ -0,0 +1,109 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// A delegating that scopes session keys by an isolation key
+/// provided by a , ensuring that sessions are isolated
+/// per logical partition (e.g., user, tenant, or composite key).
+///
+public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
+{
+ private readonly SessionIsolationKeyProvider? _keyProvider;
+ private readonly bool _strict;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying to delegate to.
+ ///
+ /// The used to retrieve the isolation key for the current context.
+ ///
+ /// The options for configuring the session store. If null, defaults are used.
+ ///
+ /// is .
+ ///
+ public IsolationKeyScopedAgentSessionStore(
+ AgentSessionStore innerStore,
+ SessionIsolationKeyProvider? keyProvider,
+ IsolationKeyScopedAgentSessionStoreOptions? options = null)
+ : base(innerStore)
+ {
+ this._keyProvider = keyProvider;
+ options ??= new IsolationKeyScopedAgentSessionStoreOptions();
+ this._strict = options.Strict;
+ }
+
+ ///
+ /// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
+ ///
+ /// The cancellation token.
+ ///
+ /// The isolation key string, or if no key is available and non-strict mode is enabled.
+ ///
+ ///
+ /// The provider returned and strict mode is enabled.
+ ///
+ private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken)
+ {
+ string? key = this._keyProvider != null
+ ? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
+ : null;
+
+ if (this._strict && key == null)
+ {
+ throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
+ }
+
+ return key;
+ }
+
+ ///
+ /// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
+ ///
+ /// The raw isolation key.
+ /// The escaped isolation key.
+ ///
+ /// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
+ /// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
+ ///
+ private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
+
+ ///
+ /// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
+ ///
+ /// The original conversation ID.
+ /// The cancellation token.
+ ///
+ /// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
+ /// if no isolation key is available and non-strict mode is enabled.
+ ///
+ private async ValueTask GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
+ {
+ string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
+ if (key == null)
+ {
+ return bareConversationId;
+ }
+
+ return $"{EscapeIsolationKey(key)}::{bareConversationId}";
+ }
+
+ ///
+ public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
+ {
+ string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
+ {
+ string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
+ await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs
new file mode 100644
index 0000000000..94f00f01bb
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Options for configuring .
+///
+public class IsolationKeyScopedAgentSessionStoreOptions
+{
+ ///
+ /// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
+ ///
+ ///
+ ///
+ /// If (default), the store will throw an
+ /// when returns .
+ ///
+ ///
+ /// If , the conversation ID is passed through unmodified when the isolation key is absent,
+ /// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios
+ /// or mixed environments where not all requests have isolation keys.
+ ///
+ ///
+ public bool Strict { get; set; } = true;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs
index 9999527505..832c411977 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs
@@ -24,6 +24,20 @@ namespace Microsoft.Agents.AI.Hosting;
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
/// such as Redis, SQL Server, or Azure Cosmos DB.
///
+///
+/// Multi-user warning. This store keys threads by
+/// (agent.Id, conversationId) only — it has no principal/owner dimension. When
+/// the conversation identifier originates from the wire (for example, an AG-UI
+/// RunAgentInput.ThreadId or an A2A contextId), any caller who knows
+/// or guesses another caller's identifier can resume that other caller's persisted
+/// thread. Multi-user hosts must wrap this store in
+/// (typically by calling
+/// UseClaimsBasedSessionIsolation(...) from
+/// Microsoft.Agents.AI.Hosting.AspNetCore or by registering a custom
+/// ) so that the conversation namespace is
+/// scoped per principal. See the trust-model remarks on
+/// for the full background.
+///
///
public sealed class InMemoryAgentSessionStore : AgentSessionStore
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs
new file mode 100644
index 0000000000..61ea82bd34
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
+///
+///
+///
+/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage
+/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes
+/// implement the key resolution logic appropriate to their hosting environment.
+///
+///
+/// When a key is unavailable or cannot be determined, implementations should return .
+/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
+/// to unscoped storage based on its configuration.
+///
+///
+public abstract class SessionIsolationKeyProvider
+{
+ ///
+ /// Asynchronously retrieves the session isolation key for the current request or execution context.
+ ///
+ /// The to monitor for cancellation requests.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains the isolation key string,
+ /// or if no key is available in the current context.
+ ///
+ ///
+ /// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
+ /// or environment variables). If the key cannot be determined, return to allow
+ /// the caller to decide on strict vs. pass-through behavior.
+ ///
+ public abstract ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj
index 407593536e..63c2bd30ae 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj
@@ -1,7 +1,7 @@
- true
+
$(NoWarn);MEAI001;OPENAI001
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj
index bca32e93fc..00865e2fa6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj
@@ -1,7 +1,7 @@
- true
+ true$(NoWarn);MEAI001;OPENAI001
@@ -13,9 +13,11 @@
-
+
- false
+ 1.8.0-rc1
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj
index 5f8cd37505..145dbe243b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj
@@ -1,7 +1,7 @@
- true
+ true$(NoWarn);MEAI001;OPENAI001
@@ -13,6 +13,13 @@
+
+
+ 1.8.0-rc1
+
+
Microsoft Agent Framework Declarative Workflows
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
index 7796a6f409..27079104a6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
@@ -27,6 +27,14 @@ internal sealed class InvokeMcpToolExecutor(
WorkflowFormulaState state) :
DeclarativeActionExecutor(model, state)
{
+ private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
+
+ ///
+ /// Snapshot of evaluated parameters at approval-request time.
+ /// Used to prevent TOCTOU attacks where state mutates during the approval window.
+ ///
+ private ApprovalSnapshot? _approvalSnapshot;
+
///
/// Step identifiers for the MCP tool invocation workflow.
///
@@ -75,18 +83,18 @@ internal sealed class InvokeMcpToolExecutor(
if (requireApproval)
{
- // Create tool call content for approval request
+ // Snapshot the evaluated parameters to prevent TOCTOU attacks.
+ // If state mutates during the approval window, the approved values are used on resume.
+ this._approvalSnapshot = new ApprovalSnapshot(serverUrl, serverLabel, toolName, arguments, connectionName);
+
+ // Create tool call content for approval request.
+ // Transport headers (e.g. Authorization) are intentionally excluded from the
+ // approval event: they must not cross into the externally-surfaced approval request.
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
{
Arguments = arguments
};
- if (headers != null)
- {
- toolCall.AdditionalProperties ??= [];
- toolCall.AdditionalProperties.Add(headers);
- }
-
ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
@@ -141,13 +149,14 @@ internal sealed class InvokeMcpToolExecutor(
return;
}
- // Approved - now invoke the tool
- string serverUrl = this.GetServerUrl();
- string? serverLabel = this.GetServerLabel();
- string toolName = this.GetToolName();
- Dictionary? arguments = this.GetArguments();
+ // Approved - use the snapshot from approval-request time to prevent TOCTOU attacks.
+ // Headers are re-evaluated (they may contain auth secrets that should not be persisted).
+ string serverUrl = this._approvalSnapshot?.ServerUrl ?? this.GetServerUrl();
+ string? serverLabel = this._approvalSnapshot?.ServerLabel ?? this.GetServerLabel();
+ string toolName = this._approvalSnapshot?.ToolName ?? this.GetToolName();
+ Dictionary? arguments = this._approvalSnapshot?.Arguments ?? this.GetArguments();
Dictionary? headers = this.GetHeaders();
- string? connectionName = this.GetConnectionName();
+ string? connectionName = this._approvalSnapshot?.ConnectionName ?? this.GetConnectionName();
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
@@ -166,9 +175,33 @@ internal sealed class InvokeMcpToolExecutor(
///
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
+ // Clear the approval snapshot after successful completion.
+ this._approvalSnapshot = null;
+ await ClearSnapshotStateAsync(context, cancellationToken).ConfigureAwait(false);
+
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
+ ///
+ ///
+ /// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
+ ///
+ protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
+ await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ /// Restores the approval snapshot from workflow state after a checkpoint restore.
+ ///
+ protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
+ this._approvalSnapshot = await context.ReadStateAsync(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
+ }
+
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
{
bool autoSend = this.GetAutoSendValue();
@@ -369,4 +402,24 @@ internal sealed class InvokeMcpToolExecutor(
return result;
}
+
+ ///
+ /// Clears the persisted approval snapshot state after a successful tool invocation.
+ ///
+ private static async ValueTask ClearSnapshotStateAsync(IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Stores the evaluated parameters at approval-request time so that
+ /// uses the values the user reviewed,
+ /// even if mutates during the approval window.
+ ///
+ internal sealed record ApprovalSnapshot(
+ string ServerUrl,
+ string? ServerLabel,
+ string ToolName,
+ Dictionary? Arguments,
+ string? ConnectionName);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs
index e57204ea4e..5d59366a20 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -19,6 +20,28 @@ public sealed class AgentResponseEvent : WorkflowOutputEvent
this.Response = Throw.IfNull(response);
}
+ ///
+ /// Initializes a new instance of the class with the given output tag.
+ ///
+ /// The identifier of the executor that generated this event.
+ /// The agent response.
+ /// The output tag to associate with this event.
+ public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : base(response, executorId, tag)
+ {
+ this.Response = Throw.IfNull(response);
+ }
+
+ ///
+ /// Initializes a new instance of the class with the given output tags.
+ ///
+ /// The identifier of the executor that generated this event.
+ /// The agent response.
+ /// The output tags to associate with this event. May be or empty.
+ public AgentResponseEvent(string executorId, AgentResponse response, IEnumerable? tags) : base(response, executorId, tags)
+ {
+ this.Response = Throw.IfNull(response);
+ }
+
///
/// Gets the agent response.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs
index 017dce1763..f3d5215ccd 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs
@@ -20,6 +20,28 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
this.Update = Throw.IfNull(update);
}
+ ///
+ /// Initializes a new instance of the class with the given output tag.
+ ///
+ /// The identifier of the executor that generated this event.
+ /// The agent run response update.
+ /// The output tag to associate with this event.
+ public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : base(update, executorId, tag)
+ {
+ this.Update = Throw.IfNull(update);
+ }
+
+ ///
+ /// Initializes a new instance of the class with the given output tags.
+ ///
+ /// The identifier of the executor that generated this event.
+ /// The agent run response update.
+ /// The output tags to associate with this event. May be or empty.
+ public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, IEnumerable? tags) : base(update, executorId, tags)
+ {
+ this.Update = Throw.IfNull(update);
+ }
+
///
/// Gets the agent run response update.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
index 9d7aa5b8c7..8321efd99e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
@@ -2,10 +2,6 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using System.Threading.Tasks;
-using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -37,31 +33,10 @@ public static partial class AgentWorkflowBuilder
{
Throw.IfNullOrEmpty(agents);
- // Create a builder that chains the agents together in sequence. The workflow simply begins
- // with the first agent in the sequence.
-
- AIAgentHostOptions options = new()
- {
- ReassignOtherAgentsAsUsers = true,
- ForwardIncomingMessages = true,
- };
-
- List agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
-
- ExecutorBinding previous = agentExecutors[0];
- WorkflowBuilder builder = new(previous);
-
- foreach (ExecutorBinding next in agentExecutors.Skip(1))
- {
- builder.AddEdge(previous, next);
- previous = next;
- }
-
- OutputMessagesExecutor end = new();
- builder = builder.AddEdge(previous, end).WithOutputFrom(end);
+ SequentialWorkflowBuilder builder = new(agents);
if (workflowName is not null)
{
- builder = builder.WithName(workflowName);
+ builder.WithName(workflowName);
}
return builder.Build();
}
@@ -107,41 +82,14 @@ public static partial class AgentWorkflowBuilder
{
Throw.IfNull(agents);
- // A workflow needs a starting executor, so we create one that forwards everything to each agent.
- ChatForwardingExecutor start = new("Start");
- WorkflowBuilder builder = new(start);
-
- // For each agent, we create an executor to host it and an accumulator to batch up its output messages,
- // so that the final accumulator receives a single list of messages from each agent. Otherwise, the
- // accumulator would not be able to determine what came from what agent, as there's currently no
- // provenance tracking exposed in the workflow context passed to a handler.
-
- ExecutorBinding[] agentExecutors = (from agent in agents
- select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
- ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
- builder.AddFanOutEdge(start, agentExecutors);
-
- for (int i = 0; i < agentExecutors.Length; i++)
- {
- builder.AddEdge(agentExecutors[i], accumulators[i]);
- }
-
- // Create the accumulating executor that will gather the results from each agent, and connect
- // each agent's accumulator to it. If no aggregation function was provided, we default to returning
- // the last message from each agent
- aggregator ??= static lists => (from list in lists where list.Count > 0 select list.Last()).ToList();
-
- Func> endFactory =
- (_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
-
- ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
-
- builder.AddFanInBarrierEdge(accumulators, end);
-
- builder = builder.WithOutputFrom(end);
+ ConcurrentWorkflowBuilder builder = new(agents);
if (workflowName is not null)
{
- builder = builder.WithName(workflowName);
+ builder.WithName(workflowName);
+ }
+ if (aggregator is not null)
+ {
+ builder.WithAggregator(aggregator);
}
return builder.Build();
}
@@ -155,7 +103,6 @@ public static partial class AgentWorkflowBuilder
/// The must be capable of understanding those provided. If the agent
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
///
- [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
{
Throw.IfNull(initialAgent);
@@ -179,4 +126,31 @@ public static partial class AgentWorkflowBuilder
Throw.IfNull(managerFactory);
return new GroupChatWorkflowBuilder(managerFactory);
}
+
+ /// Creates a new with the given pipeline of .
+ /// The sequence of agents to compose into a sequential workflow.
+ /// The builder for creating a sequential workflow.
+ public static SequentialWorkflowBuilder CreateSequentialBuilderWith(params IEnumerable agents)
+ {
+ Throw.IfNull(agents);
+ return new SequentialWorkflowBuilder(agents);
+ }
+
+ /// Creates a new with the given participating .
+ /// The set of agents to compose into a concurrent workflow.
+ /// The builder for creating a concurrent workflow.
+ public static ConcurrentWorkflowBuilder CreateConcurrentBuilderWith(params IEnumerable agents)
+ {
+ Throw.IfNull(agents);
+ return new ConcurrentWorkflowBuilder(agents);
+ }
+
+ /// Creates a new with the given .
+ /// The LLM-powered manager agent that coordinates the team.
+ /// The builder for creating a Magentic workflow.
+ public static MagenticWorkflowBuilder CreateMagenticBuilderWith(AIAgent managerAgent)
+ {
+ Throw.IfNull(managerAgent);
+ return new MagenticWorkflowBuilder(managerAgent);
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs
index f40882265a..aac14fee35 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
@@ -15,14 +16,14 @@ internal sealed class WorkflowInfo
Dictionary> edges,
HashSet requestPorts,
string startExecutorId,
- HashSet? outputExecutorIds)
+ Dictionary>? outputExecutorIds)
{
this.Executors = Throw.IfNull(executors);
this.Edges = Throw.IfNull(edges);
this.RequestPorts = Throw.IfNull(requestPorts);
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
- this.OutputExecutorIds = outputExecutorIds ?? [];
+ this.OutputExecutorIds = outputExecutorIds ?? new Dictionary>(StringComparer.Ordinal);
}
public Dictionary Executors { get; }
@@ -32,7 +33,15 @@ internal sealed class WorkflowInfo
public TypeId? InputType { get; }
public string StartExecutorId { get; }
- public HashSet OutputExecutorIds { get; }
+ ///
+ /// Map of executor id to the set of s under which the executor is registered.
+ /// An empty set means the executor is registered as a regular (untagged) output source.
+ /// JSON shape: { "executorId": ["intermediate"], ... }. Legacy payloads using the
+ /// older string[] shape are read by and
+ /// each id is treated as registered with an empty tag set.
+ ///
+ [JsonConverter(typeof(WorkflowInfoOutputExecutorsConverter))]
+ public Dictionary> OutputExecutorIds { get; }
public bool IsMatch(Workflow workflow)
{
@@ -80,9 +89,12 @@ internal sealed class WorkflowInfo
return false;
}
- // Validate the outputs
+ // Validate the outputs (key set + tag set per id must match)
if (workflow.OutputExecutors.Count != this.OutputExecutorIds.Count ||
- this.OutputExecutorIds.Any(id => !workflow.OutputExecutors.Contains(id)))
+ this.OutputExecutorIds.Any(kvp =>
+ !workflow.OutputExecutors.TryGetValue(kvp.Key, out HashSet? tags) ||
+ tags.Count != kvp.Value.Count ||
+ !tags.SetEquals(kvp.Value)))
{
return false;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs
new file mode 100644
index 0000000000..8ee8d39590
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Agents.AI.Workflows.Checkpointing;
+
+///
+/// JSON converter for that supports both the new
+/// map shape ({ "id": ["intermediate"] }) and the legacy array shape
+/// (["id1", "id2"]). Legacy-shaped payloads are read as if every id had been registered
+/// as a regular (untagged) output source; output is always written in the new map shape.
+///
+internal sealed class WorkflowInfoOutputExecutorsConverter : JsonConverter>>
+{
+ public override Dictionary> Read(
+ ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ Dictionary> result = new(StringComparer.Ordinal);
+
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return result;
+ }
+
+ if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ // Legacy shape: a flat array of executor ids. Treat each as a registered
+ // (untagged) output executor.
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ {
+ return result;
+ }
+
+ if (reader.TokenType != JsonTokenType.String)
+ {
+ throw new JsonException($"Expected a string in legacy outputExecutorIds array, got {reader.TokenType}.");
+ }
+
+ string id = reader.GetString()!;
+ result[id] = [];
+ }
+
+ throw new JsonException("Unexpected end of legacy outputExecutorIds array.");
+ }
+
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ throw new JsonException($"Expected object or array for outputExecutorIds, got {reader.TokenType}.");
+ }
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ return result;
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ throw new JsonException($"Expected property name in outputExecutorIds object, got {reader.TokenType}.");
+ }
+
+ string id = reader.GetString()!;
+ reader.Read();
+
+ HashSet tags = [];
+ if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ if (reader.TokenType != JsonTokenType.String)
+ {
+ throw new JsonException($"Expected a string tag, got {reader.TokenType}.");
+ }
+
+ tags.Add(ReadTag(reader.GetString()!));
+ }
+ }
+ else
+ {
+ throw new JsonException($"Expected array of tags for outputExecutorIds[{id}], got {reader.TokenType}.");
+ }
+
+ result[id] = tags;
+ }
+
+ throw new JsonException("Unexpected end of outputExecutorIds object.");
+ }
+
+ private static OutputTag ReadTag(string value)
+ {
+ if (string.Equals(value, OutputTag.Intermediate.Value, StringComparison.Ordinal))
+ {
+ return OutputTag.Intermediate;
+ }
+ return new OutputTag(value);
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ Dictionary> value,
+ JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ foreach (KeyValuePair> kvp in value)
+ {
+ writer.WritePropertyName(kvp.Key);
+ writer.WriteStartArray();
+ foreach (OutputTag tag in kvp.Value)
+ {
+ writer.WriteStringValue(tag.Value);
+ }
+ writer.WriteEndArray();
+ }
+ writer.WriteEndObject();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs
new file mode 100644
index 0000000000..feb31ddd9f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ConcurrentWorkflowBuilder.cs
@@ -0,0 +1,104 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Specialized;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Fluent builder for concurrent agent workflows: a fan-out start that broadcasts the
+/// incoming messages to every participating agent, a per-agent accumulator that batches
+/// each agent's outgoing messages, and a fan-in aggregator that reduces them into a
+/// single output list.
+///
+///
+/// When no explicit output designations are made, the default is the Python-aligned
+/// shape: the terminal aggregator is the workflow output, and every participating agent
+/// (plus its per-agent accumulator) is designated as an intermediate output source.
+/// Calling
+/// or
+/// at all suppresses these defaults.
+///
+public sealed class ConcurrentWorkflowBuilder : OrchestrationBuilderBase
+{
+ private readonly List _agents = [];
+ private Func>, List>? _aggregator;
+
+ ///
+ /// Initializes a new with the given participating
+ /// .
+ ///
+ public ConcurrentWorkflowBuilder(params IEnumerable agents)
+ {
+ Throw.IfNull(agents);
+ foreach (AIAgent agent in agents)
+ {
+ Throw.IfNull(agent, nameof(agents));
+ this._agents.Add(agent);
+ }
+ }
+
+ ///
+ /// Sets the aggregator function. If not called, defaults to returning the last message
+ /// from each agent that produced at least one message.
+ ///
+ public ConcurrentWorkflowBuilder WithAggregator(Func>, List> aggregator)
+ {
+ this._aggregator = Throw.IfNull(aggregator);
+ return this;
+ }
+
+ /// Builds the configured concurrent workflow.
+ public Workflow Build()
+ {
+ if (this._agents.Count == 0)
+ {
+ throw new ArgumentException("At least one agent must be provided to the ConcurrentWorkflowBuilder.", "agents");
+ }
+
+ ChatForwardingExecutor start = new("Start");
+ WorkflowBuilder builder = new(start);
+
+ Dictionary agentMap = new(AIAgentIDEqualityComparer.Instance);
+ ExecutorBinding[] agentExecutors = new ExecutorBinding[this._agents.Count];
+ ExecutorBinding[] accumulators = new ExecutorBinding[this._agents.Count];
+ AIAgentHostOptions options = new() { ReassignOtherAgentsAsUsers = true };
+ for (int i = 0; i < this._agents.Count; i++)
+ {
+ AIAgent agent = this._agents[i];
+ ExecutorBinding binding = agent.BindAsExecutor(options);
+ agentExecutors[i] = binding;
+ agentMap[agent] = binding;
+ accumulators[i] = new AggregateTurnMessagesExecutor($"Batcher/{binding.Id}");
+ }
+
+ builder.AddFanOutEdge(start, agentExecutors);
+ for (int i = 0; i < agentExecutors.Length; i++)
+ {
+ builder.AddEdge(agentExecutors[i], accumulators[i]);
+ }
+
+ Func>, List> aggregator =
+ this._aggregator ?? (static lists => (from list in lists where list.Count > 0 select list.Last()).ToList());
+
+ Func> endFactory =
+ (_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
+
+ ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
+ builder.AddFanInBarrierEdge(accumulators, end);
+
+ this.ApplyMetadata(builder);
+ this.ApplyOutputDesignations(builder, agentMap, "concurrent", () =>
+ {
+ builder.WithOutputFrom(end);
+ builder.WithIntermediateOutputFrom([.. agentExecutors, .. accumulators]);
+ });
+
+ return builder.Build();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs
index cecf1da9f8..5ef5b8713b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs
@@ -1,11 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class OutputFilter(Workflow workflow)
{
public bool CanOutput(string sourceExecutorId, object output)
{
- return workflow.OutputExecutors.Contains(sourceExecutorId);
+ return workflow.OutputExecutors.ContainsKey(sourceExecutorId);
}
+
+ public bool TryGetTags(string sourceExecutorId, [NotNullWhen(true)] out HashSet? tags)
+ => workflow.OutputExecutors.TryGetValue(sourceExecutorId, out tags);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs
new file mode 100644
index 0000000000..b2c83f112a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Process-wide opt-in switches for in-development behavior changes that will become
+/// the default in a future major release. Each flag defaults to
+/// and should be toggled once at application startup.
+///
+public static class Futures
+{
+ ///
+ /// When , and
+ /// payloads yielded by an executor participate
+ /// in the normal output-filter pipeline (i.e. they must be designated via
+ /// or
+ ///
+ /// to surface), and the resulting s carry
+ /// reflecting that designation.
+ ///
+ ///
+ ///
+ /// When (the current default), the runner emits
+ /// and unconditionally,
+ /// bypassing the output filter (historical behavior). Lifecycle: opt-in today, marked
+ /// [Obsolete] in v2.0.0 when the new behavior becomes default, and removed in v3.0.0.
+ ///
+ ///
+ /// Interaction with . When this flag
+ /// is , joins
+ /// in being forwarded out of the agent surface
+ /// unconditionally — neither honors the host's includeWorkflowOutputsInResponse
+ /// switch. That switch only governs the generic path for
+ /// non-AIAgent payloads. When this flag is , the legacy asymmetry
+ /// is preserved: is always forwarded but
+ /// stays gated by includeWorkflowOutputsInResponse.
+ ///
+ ///
+ public static bool EnableAgentResponseOutputTaggingAndFiltering { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs
index d16a4b5b43..ab94a9fa8a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -13,6 +15,16 @@ namespace Microsoft.Agents.AI.Workflows;
///
public abstract class GroupChatManager
{
+ // The state key under which GroupChatManager persists its own (non-subclass) state on the
+ // raw IWorkflowContext supplied by the hosting GroupChatHost executor.
+ internal const string BaseStateKey = "GroupChatManager";
+
+ // Prefix automatically applied to every key a subclass writes through the wrapped context
+ // supplied to OnCheckpointingAsync / OnCheckpointRestoredAsync. Keeps subclass-defined
+ // state in its own namespace so it cannot collide with the host's state keys nor with
+ // BaseStateKey itself.
+ internal const string SubclassStateKeyPrefix = "GroupChatManager_";
+
///
/// Initializes a new instance of the class.
///
@@ -48,12 +60,22 @@ public abstract class GroupChatManager
CancellationToken cancellationToken = default);
///
- /// Filters the chat history before it's passed to the next agent.
+ /// Filters the messages broadcast to participants for the current turn.
///
- /// The chat history to filter.
+ ///
+ /// Under the broadcast model, each participant maintains its own per-agent session (history)
+ /// through its . The host distributes new messages
+ /// (initial user input on the first turn, the most recent speaker's response on subsequent turns)
+ /// to every participant — except the speaker that produced them — so every participant's session
+ /// stays synchronized. This method lets the manager shape that broadcast payload (for example,
+ /// to omit certain messages or to inject orchestrator-visible annotations). The full canonical
+ /// conversation is still available to and
+ /// .
+ ///
+ /// The new messages about to be broadcast to participants this turn.
/// The to monitor for cancellation requests.
/// The default is .
- /// The filtered chat history.
+ /// The filtered message list to broadcast.
protected internal virtual ValueTask> UpdateHistoryAsync(
IReadOnlyList history,
CancellationToken cancellationToken = default) =>
@@ -78,4 +100,125 @@ public abstract class GroupChatManager
{
this.IterationCount = 0;
}
+
+ ///
+ /// Invoked when the hosting group chat workflow is checkpointing, giving subclasses a chance to
+ /// persist any additional state they maintain (e.g., a round-robin cursor or an LLM session).
+ ///
+ ///
+ ///
+ /// The default implementation is a no-op. Base-class state (currently
+ /// ) is persisted automatically by the hosting
+ /// before this method is invoked; subclasses do not
+ /// need to call base.OnCheckpointingAsync.
+ ///
+ ///
+ /// The supplied is a wrapper that transparently prefixes every
+ /// state key with "GroupChatManager_", isolating subclass state from the host's own
+ /// state keys (and from the reserved base-state key). Implementations therefore may use any
+ /// human-readable key (e.g., "next_index") without worrying about collisions.
+ ///
+ ///
+ /// A wrapped workflow context that scopes state keys to the
+ /// subclass namespace.
+ /// The to monitor for cancellation requests.
+ /// The default is .
+ protected virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ => default;
+
+ ///
+ /// Invoked when the hosting group chat workflow is being restored from a checkpoint, giving
+ /// subclasses a chance to hydrate any additional state they persisted in
+ /// .
+ ///
+ ///
+ /// The default implementation is a no-op. Base-class state (currently
+ /// ) is restored automatically by the hosting
+ /// before this method is invoked; subclasses do not
+ /// need to call base.OnCheckpointRestoredAsync. The supplied
+ /// uses the same key-prefixing wrapper as .
+ ///
+ /// A wrapped workflow context that scopes state keys to the
+ /// subclass namespace.
+ /// The to monitor for cancellation requests.
+ /// The default is .
+ protected virtual ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ => default;
+
+ // Root checkpoint entry point invoked by the hosting GroupChatHost. Persists the manager's
+ // own base state under the reserved BaseStateKey on the raw context, then delegates to the
+ // subclass-facing OnCheckpointingAsync hook with a wrapped context that prefixes every key
+ // with SubclassStateKeyPrefix.
+ internal async ValueTask CheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ await context.QueueStateUpdateAsync(BaseStateKey, new GroupChatManagerState(this.IterationCount), cancellationToken: cancellationToken).ConfigureAwait(false);
+ await this.OnCheckpointingAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
+ }
+
+ // Root restore entry point invoked by the hosting GroupChatHost. Symmetric to CheckpointAsync.
+ internal async ValueTask RestoreCheckpointAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ GroupChatManagerState? state = await context.ReadStateAsync(BaseStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
+ this.IterationCount = state?.IterationCount ?? 0;
+ await this.OnCheckpointRestoredAsync(new PrefixingWorkflowContext(context, SubclassStateKeyPrefix), cancellationToken).ConfigureAwait(false);
+ }
+}
+
+internal sealed record GroupChatManagerState(int IterationCount);
+
+// IWorkflowContext decorator that prepends a fixed prefix to every state key passed through it.
+// All non-state members (events, message sending, output yielding, halt requests, trace context,
+// and runtime characteristics) delegate directly to the wrapped context.
+internal sealed class PrefixingWorkflowContext(IWorkflowContext inner, string prefix) : IWorkflowContext
+{
+ private readonly IWorkflowContext _inner = Throw.IfNull(inner);
+ private readonly string _prefix = Throw.IfNullOrEmpty(prefix);
+
+ public IReadOnlyDictionary? TraceContext => this._inner.TraceContext;
+
+ public bool ConcurrentRunsEnabled => this._inner.ConcurrentRunsEnabled;
+
+ public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
+ => this._inner.AddEventAsync(workflowEvent, cancellationToken);
+
+ public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default)
+ => this._inner.SendMessageAsync(message, targetId, cancellationToken);
+
+ public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
+ => this._inner.YieldOutputAsync(output, cancellationToken);
+
+ public ValueTask RequestHaltAsync() => this._inner.RequestHaltAsync();
+
+ public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default)
+ => this._inner.ReadStateAsync(this.Wrap(key), scopeName, cancellationToken);
+
+ public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
+ => this._inner.ReadOrInitStateAsync(this.Wrap(key), initialStateFactory, scopeName, cancellationToken);
+
+ public async ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
+ {
+ HashSet rawKeys = await this._inner.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
+ return [.. rawKeys.Where(k => k.StartsWith(this._prefix, StringComparison.Ordinal))
+ .Select(k => k.Substring(this._prefix.Length))];
+ }
+
+ public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
+ => this._inner.QueueStateUpdateAsync(this.Wrap(key), value, scopeName, cancellationToken);
+
+ public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
+ {
+ // Clearing the entire underlying scope would also remove keys owned by the host and other
+ // subsystems sharing the executor's default scope. Restrict the clear to keys carrying
+ // this wrapper's prefix.
+ HashSet rawKeys = await this._inner.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false);
+ foreach (string rawKey in rawKeys)
+ {
+ if (rawKey.StartsWith(this._prefix, StringComparison.Ordinal))
+ {
+ await this._inner.QueueStateUpdateAsync