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.0 1 - 260526 + 260603 $(VersionPrefix)-rc$(RCNumber) $(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1 $(VersionPrefix)-preview.$(DateSuffix).1 $(VersionPrefix) - 1.7.0 + 1.9.0 Debug;Release;Publish true 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(rawKey, null, scopeName, cancellationToken).ConfigureAwait(false); + } + } + } + + private string Wrap(string key) => this._prefix + Throw.IfNullOrEmpty(key); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs index 66e4429e35..14167a2e8f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -12,12 +12,10 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Provides a builder for specifying group chat relationships between agents and building the resulting workflow. /// -public sealed class GroupChatWorkflowBuilder +public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase { private readonly Func, GroupChatManager> _managerFactory; private readonly HashSet _participants = new(AIAgentIDEqualityComparer.Instance); - private string _name = string.Empty; - private string _description = string.Empty; internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => this._managerFactory = managerFactory; @@ -44,28 +42,6 @@ public sealed class GroupChatWorkflowBuilder return this; } - /// - /// Sets the human-readable name for the workflow. - /// - /// The name of the workflow. - /// This instance of the . - public GroupChatWorkflowBuilder WithName(string name) - { - this._name = name; - return this; - } - - /// - /// Sets the description for the workflow. - /// - /// The description of what the workflow does. - /// This instance of the . - public GroupChatWorkflowBuilder WithDescription(string description) - { - this._description = description; - return this; - } - /// /// Builds a composed of agents that operate via group chat, with the next /// agent to process messages selected by the group chat manager. @@ -75,10 +51,14 @@ public sealed class GroupChatWorkflowBuilder { AIAgent[] agents = this._participants.ToArray(); + // GroupChatHost owns the canonical conversation and broadcasts messages directly to every + // participant. Participants therefore must not echo their incoming messages back to the host + // (which would cause duplicates), but must still reframe other agents' assistant messages as + // user messages so each agent's own session reads coherently. AIAgentHostOptions options = new() { ReassignOtherAgentsAsUsers = true, - ForwardIncomingMessages = true + ForwardIncomingMessages = false }; Dictionary agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options)); @@ -89,15 +69,7 @@ public sealed class GroupChatWorkflowBuilder ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost)); WorkflowBuilder builder = new(host); - if (!string.IsNullOrEmpty(this._name)) - { - builder = builder.WithName(this._name); - } - - if (!string.IsNullOrEmpty(this._description)) - { - builder = builder.WithDescription(this._description); - } + this.ApplyMetadata(builder); foreach (var participant in agentMap.Values) { @@ -106,6 +78,15 @@ public sealed class GroupChatWorkflowBuilder .AddEdge(participant, host); } - return builder.WithOutputFrom(host).Build(); + this.ApplyOutputDesignations(builder, agentMap, "group chat", () => + { + builder.WithOutputFrom(host); + if (agentMap.Count > 0) + { + builder.WithIntermediateOutputFrom([.. agentMap.Values]); + } + }); + + return builder.Build(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 7142faad0b..906ebab885 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -4,6 +4,7 @@ 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; @@ -14,11 +15,6 @@ using ExecutorFactoryFunc = System.Func [ExcludeFromCodeCoverage] // This is obsolete, and 1:1 equivalent to HandoffWorkflowBuilder (no "s") [Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] @@ -29,7 +25,6 @@ public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkf } /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) { } @@ -37,8 +32,8 @@ public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkfl /// /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] -public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore +public class HandoffWorkflowBuilderCore : OrchestrationBuilderBase + where TBuilder : HandoffWorkflowBuilderCore { /// /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, @@ -54,8 +49,22 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; private bool _returnToPrevious; - private string? _name; - private string? _description; + + // Autonomous mode configuration. When enabled, an agent's response that doesn't include a + // handoff triggers another invocation of that same agent with the continuation prompt, up to + // the configured turn limit per workflow turn. Optional per-agent overrides may further restrict + // which agents have autonomous mode enabled, or override the turn limit / continuation prompt + // on a per-agent basis. + private bool _autonomousMode; + private int _autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit; + private string _autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt; + private HashSet? _autonomousEnabledAgentIds; + private readonly Dictionary _autonomousTurnLimitsByAgentId = []; + private readonly Dictionary _autonomousContinuationPromptsByAgentId = []; + + // Termination condition. Evaluated after an agent response that does not request a handoff; + // if true, the workflow ends (and the autonomous loop, if any, terminates). + private Func, ValueTask>? _terminationCondition; /// /// Initializes a new instance of the class with no handoff relationships. @@ -99,20 +108,6 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } - /// - public TBuilder WithName(string name) - { - this._name = name; - return (TBuilder)this; - } - - /// - public TBuilder WithDescription(string description) - { - this._description = description; - return (TBuilder)this; - } - /// /// Sets a value indicating whether agent streaming update events should be emitted during execution. /// If , the value will be taken from the @@ -258,12 +253,204 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } - private Dictionary CreateExecutorBindings(WorkflowBuilder builder) + /// + /// Adds the specified as participants in the handoff workflow without + /// defining handoff relationships for them. + /// + /// The agents to add as participants. + /// The updated builder instance. + /// + /// Use this method when you want a participant to be part of the workflow but you have not + /// explicitly defined handoff edges via . + /// When no handoffs are explicitly defined (default handoffs), all registered participants are + /// automatically wired so that every agent can hand off to every other agent. + /// + public TBuilder AddParticipants(params IEnumerable agents) + { + Throw.IfNull(agents); + + foreach (AIAgent agent in agents) + { + if (agent is null) + { + Throw.ArgumentNullException(nameof(agents), "One or more agents are null."); + } + + this._allAgents.Add(agent); + } + + return (TBuilder)this; + } + + /// + /// Enables autonomous mode for the handoff workflow. + /// + /// + /// + /// In autonomous mode, an agent whose response does not include a handoff is invoked again with + /// a continuation prompt, up to a configured turn limit. The autonomous loop for a given agent + /// ends when the agent invokes a handoff tool, the configured termination condition fires, or + /// the per-agent turn limit is reached — at which point the workflow yields control back to the + /// caller. + /// + /// + /// Per-agent turn counting. Autonomous-turn counters are tracked independently per agent + /// in the shared handoff state. A counter is incremented each time the End executor loops + /// control back to its source agent, and reset to zero in three cases: (1) when that agent + /// requests a handoff, (2) when its autonomous loop terminates (limit reached, termination + /// fires, or autonomous mode disabled for that agent), and (3) at the start of every fresh user + /// turn. As a consequence, if agent A loops twice and then hands off to B, A's counter resets + /// to zero; should control later return to A within the same user turn, A starts a new + /// autonomous run from zero. + /// + /// + /// + /// The default maximum number of autonomous continuation iterations per agent per workflow + /// turn. Applies to agents not listed in . If + /// , defaults to + /// (50). + /// + /// + /// The default user-role prompt fed to an agent on each autonomous continuation. Applies to + /// agents not listed in . If , + /// defaults to . + /// + /// + /// Optional allow-list restricting autonomous mode to a specific subset of agents. If + /// or empty, autonomous mode is enabled for every participant. + /// Agents not in the allow-list always yield control back to the caller after a single + /// invocation (when they do not request a handoff). + /// + /// + /// Optional per-agent turn-limit overrides. Each entry's key is the agent and its value the + /// turn limit that overrides for that agent. Agents not present + /// fall back to the default. + /// + /// + /// Optional per-agent continuation-prompt overrides. Each entry's key is the agent and its + /// value the continuation prompt used for that agent. Agents not present fall back to the + /// default. + /// + /// The updated builder instance. + public TBuilder WithAutonomousMode( + int? turnLimit = null, + string? continuationPrompt = null, + IEnumerable? agents = null, + IReadOnlyDictionary? agentTurnLimits = null, + IReadOnlyDictionary? agentContinuationPrompts = null) + { + if (turnLimit is { } limit && limit <= 0) + { + Throw.ArgumentOutOfRangeException(nameof(turnLimit), "Turn limit must be greater than zero."); + } + + this._autonomousMode = true; + this._autonomousTurnLimit = turnLimit ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit; + this._autonomousContinuationPrompt = continuationPrompt ?? HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt; + + // Allow-list: null or empty means every participant has autonomous mode enabled. A non-empty + // list restricts autonomous mode to exactly those agents. + this._autonomousEnabledAgentIds = null; + if (agents is not null) + { + HashSet ids = []; + foreach (AIAgent agent in agents) + { + Throw.IfNull(agent, $"{nameof(agents)} element"); + ids.Add(agent.Id); + } + + if (ids.Count > 0) + { + this._autonomousEnabledAgentIds = ids; + } + } + + this._autonomousTurnLimitsByAgentId.Clear(); + if (agentTurnLimits is not null) + { + foreach (KeyValuePair kvp in agentTurnLimits) + { + Throw.IfNull(kvp.Key, $"{nameof(agentTurnLimits)} key"); + if (kvp.Value <= 0) + { + Throw.ArgumentOutOfRangeException( + nameof(agentTurnLimits), + $"Turn limit for agent '{kvp.Key.Name ?? kvp.Key.Id}' must be greater than zero."); + } + + this._autonomousTurnLimitsByAgentId[kvp.Key.Id] = kvp.Value; + } + } + + this._autonomousContinuationPromptsByAgentId.Clear(); + if (agentContinuationPrompts is not null) + { + foreach (KeyValuePair kvp in agentContinuationPrompts) + { + Throw.IfNull(kvp.Key, $"{nameof(agentContinuationPrompts)} key"); + Throw.IfNullOrEmpty(kvp.Value, $"{nameof(agentContinuationPrompts)} value"); + + this._autonomousContinuationPromptsByAgentId[kvp.Key.Id] = kvp.Value; + } + } + + return (TBuilder)this; + } + + /// + /// Sets a synchronous termination condition for the handoff workflow. + /// + /// + /// A predicate that receives the current conversation and returns if the + /// workflow should terminate (preventing further autonomous continuation). The synchronous + /// predicate is wrapped and forwarded to the async overload. + /// + /// The updated builder instance. + /// + /// The termination condition is evaluated after the agent produces a response that does not + /// request a handoff. When it returns , the workflow ends without invoking + /// another autonomous continuation. + /// + public TBuilder WithTerminationCondition(Func, bool> terminationCondition) + { + Throw.IfNull(terminationCondition); + + return this.WithTerminationCondition( + messages => new ValueTask(terminationCondition(messages))); + } + + /// + /// Sets an asynchronous termination condition for the handoff workflow. + /// + /// + /// A predicate that receives the current conversation and asynchronously returns + /// if the workflow should terminate (preventing further autonomous + /// continuation). + /// + /// The updated builder instance. + /// + /// The termination condition is evaluated after the agent produces a response that does not + /// request a handoff. When it returns , the workflow ends without invoking + /// another autonomous continuation. + /// + public TBuilder WithTerminationCondition(Func, ValueTask> terminationCondition) + { + Throw.IfNull(terminationCondition); + + this._terminationCondition = terminationCondition; + return (TBuilder)this; + } + + private Dictionary CreateExecutorBindings(WorkflowBuilder builder, Dictionary> effectiveTargets) { HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._emitAgentResponseEvents, this._emitAgentResponseUpdateEvents, - this._toolCallFilteringBehavior); + this._toolCallFilteringBehavior) + { + TerminationCondition = this._terminationCondition, + }; // There are two types of ids being used in this method, and it is critical that we are clear about // which one we are using, and where. @@ -277,7 +464,7 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl ExecutorBinding CreateFactoryBinding(AIAgent agent) { - if (!this._targets.TryGetValue(agent, out HashSet? handoffs)) + if (!effectiveTargets.TryGetValue(agent, out HashSet? handoffs)) { handoffs = new(); } @@ -287,10 +474,16 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl { foreach (HandoffTarget handoff in handoffs) { - sb.AddCase(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching + // Each handoff case also requires the turn to NOT be terminated; otherwise the + // turn falls through to the default branch, which routes to HandoffEndExecutor. + string targetAgentId = handoff.Target.Id; + sb.AddCase(state => state?.RequestedHandoffTargetAgentId == targetAgentId // Use AgentId for target matching + && state.IsTerminated != true, HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level } + // Default branch catches: (a) turns with no handoff requested, and (b) terminated turns + // (whose handoff cases have been excluded above via the !IsTerminated guard). sb.WithDefault(HandoffEndExecutor.ExecutorId); }); @@ -309,6 +502,47 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl } } + private Dictionary> BuildDefaultHandoffTargets() + { + // Default handoffs: when the caller has not explicitly registered any handoffs via + // WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other + // participant. + // The handoff "reason" is derived from the target agent's description/name/instructions, + // matching the resolution rules used in WithHandoff(). If no reason can be derived, we throw — + // same contract as the explicit handoff path. + Dictionary> defaultTargets = []; + + foreach (AIAgent source in this._allAgents) + { + HashSet targets = []; + foreach (AIAgent target in this._allAgents) + { + if (AIAgentIDEqualityComparer.Instance.Equals(source, target)) + { + continue; + } + + string? reason = (string.IsNullOrWhiteSpace(target.Description) ? null : target.Description) + ?? (string.IsNullOrWhiteSpace(target.Name) ? null : $"handoff to {target.Name}") + ?? target.GetService()?.Instructions; + + if (string.IsNullOrWhiteSpace(reason)) + { + Throw.InvalidOperationException( + $"Cannot build default handoffs: target agent '{(string.IsNullOrWhiteSpace(target.Name) ? target.Id : target.Name)}' " + + "has no description, name, or instructions from which to derive a handoff reason. Either provide one of these " + + "on the agent, or define handoffs explicitly via WithHandoff/WithHandoffs."); + } + + targets.Add(new HandoffTarget(target, reason)); + } + + defaultTargets[source] = targets; + } + + return defaultTargets; + } + /// /// Builds a composed of agents that operate via handoffs, with the next /// agent to process messages selected by the current agent. @@ -317,11 +551,25 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl public Workflow Build() { HandoffStartExecutor start = new(this._returnToPrevious); - HandoffEndExecutor end = new(this._returnToPrevious); + HandoffEndExecutor end = new( + returnToPrevious: this._returnToPrevious, + autonomousMode: this._autonomousMode, + autonomousTurnLimit: this._autonomousTurnLimit, + autonomousContinuationPrompt: this._autonomousContinuationPrompt, + autonomousEnabledAgentIds: this._autonomousEnabledAgentIds, + autonomousTurnLimitsByAgentId: this._autonomousTurnLimitsByAgentId, + autonomousContinuationPromptsByAgentId: this._autonomousContinuationPromptsByAgentId); WorkflowBuilder builder = new(start); + // Default handoffs: when the caller has not explicitly registered any handoffs via + // WithHandoff/WithHandoffs, every registered participant is wired to hand off to every other + // participant. + Dictionary> effectiveTargets = this._targets.Count == 0 + ? this.BuildDefaultHandoffTargets() + : this._targets; + // Create an factory-based ExecutorBinding for each agent. - Dictionary executors = this.CreateExecutorBindings(builder); + Dictionary executors = this.CreateExecutorBindings(builder, effectiveTargets); // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). if (this._returnToPrevious) @@ -346,16 +594,46 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl builder.AddEdge(start, executors[this._initialAgent.Id]); } - if (!string.IsNullOrWhiteSpace(this._name)) + // Autonomous-mode loop-back: when enabled, the End executor may emit a HandoffState targeting + // the source agent (carrying the synthesized continuation prompt in the shared conversation). + // A switch downstream of End routes that message back to the matching agent executor. + if (this._autonomousMode) { - builder.WithName(this._name); + builder.AddSwitch(end, sb => + { + foreach (AIAgent agent in this._allAgents) + { + string agentId = agent.Id; + sb.AddCase(state => state?.RequestedHandoffTargetAgentId == agentId, executors[agentId]); + } + }); } - if (!string.IsNullOrWhiteSpace(this._description)) + // Ensure the end executor is bound regardless of whether it ends up as an output + // designation source — the user may take full control of output designations. + builder.BindExecutor(end); + + // Build the AIAgent -> ExecutorBinding map the base helper expects. + Dictionary agentMap = new(AIAgentIDEqualityComparer.Instance); + foreach (AIAgent agent in this._allAgents) { - builder.WithDescription(this._description); + agentMap[agent] = executors[agent.Id]; } - return builder.WithOutputFrom(end).Build(); + this.ApplyMetadata(builder); + this.ApplyOutputDesignations(builder, agentMap, "handoff", () => + { + // Defaults (matches Python's Handoff orchestration): + // end -> terminal output + // every handoff agent -> intermediate output + builder.WithOutputFrom(end); + List agentBindings = [.. executors.Values]; + if (agentBindings.Count > 0) + { + builder.WithIntermediateOutputFrom(agentBindings); + } + }); + + return builder.Build(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index d6c7d301e3..8201f8d8fe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -241,30 +241,47 @@ internal sealed class InProcessRunnerContext : IRunnerContext this.CheckEnded(); Throw.IfNull(output); - // Special-case AgentResponse and AgentResponseUpdate to create their specific event types - // and bypass the output filter (for backwards compatibility - these events were previously - // emitted directly via AddEventAsync without filtering) - if (output is AgentResponseUpdate update) + bool isAgentResponseShaped = output is AgentResponse or AgentResponseUpdate; + + if (isAgentResponseShaped && !Futures.EnableAgentResponseOutputTaggingAndFiltering) { - await this.AddEventAsync(new AgentResponseUpdateEvent(sourceId, update), cancellationToken).ConfigureAwait(false); - return; - } - else if (output is AgentResponse response) - { - await this.AddEventAsync(new AgentResponseEvent(sourceId, response), cancellationToken).ConfigureAwait(false); + // Legacy bypass: AgentResponse/AgentResponseUpdate skip the output filter and are + // emitted as their typed event subclasses with no tags. Preserved verbatim for + // back-compat; once Futures.EnableAgentResponseOutputTaggingAndFiltering becomes the + // default in v2.0.0, this branch goes away. + WorkflowEvent typedEvent = output switch + { + AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u), + AgentResponse r => new AgentResponseEvent(sourceId, r), + _ => throw new InvalidOperationException("Unexpected AIAgent-shaped payload type."), + }; + await this.AddEventAsync(typedEvent, cancellationToken).ConfigureAwait(false); return; } Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false); - if (!sourceExecutor.CanOutput(output.GetType())) + if (!isAgentResponseShaped && !sourceExecutor.CanOutput(output.GetType())) { + // AIAgent-shaped payloads bypass the per-executor declared-yield check (matching the + // legacy bypass branch above). The AIAgent host executor relays the agent's output + // without declaring AgentResponse(Update) in its Yields set, so a CanOutput probe + // here would always reject — but those payloads are always a valid output shape. throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}]."); } - if (this._outputFilter.CanOutput(sourceId, output)) + if (!this._outputFilter.TryGetTags(sourceId, out HashSet? tags)) { - await this.AddEventAsync(new WorkflowOutputEvent(output, sourceId), cancellationToken).ConfigureAwait(false); + // Not designated as an output source — drop silently. + return; } + + WorkflowOutputEvent evt = output switch + { + AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u, tags), + AgentResponse r => new AgentResponseEvent(sourceId, r, tags), + _ => new WorkflowOutputEvent(output, sourceId, tags), + }; + await this.AddEventAsync(evt, cancellationToken).ConfigureAwait(false); } public IExternalRequestContext BindExternalRequestContext(string executorId) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewRequest.cs index 7e66cd4c1a..fd7b82afe6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewRequest.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Extensions.AI; @@ -16,7 +15,6 @@ namespace Microsoft.Agents.AI.Workflows; /// contain the latest progress ledger that determined that no progress has been made or the workflow was in /// a loop. /// Whether the workflow is currently stalled. -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled) { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewResponse.cs index 952cd9fade..0a72ccfa0f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticPlanReviewResponse.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows; @@ -13,7 +12,6 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested. /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public record MagenticPlanReviewResponse(List Review) { internal bool IsApproved => this.Review.Count == 0; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticProgressLedger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticProgressLedger.cs index 65058e7430..698c2e890c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticProgressLedger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticProgressLedger.cs @@ -14,7 +14,6 @@ namespace Microsoft.Agents.AI.Workflows; /// /// Maintains a ledger of progress made by the Magentic workflow. /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public class MagenticProgressLedger { internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied", @@ -76,7 +75,7 @@ public class MagenticProgressLedger this.InstructionOrQuestion = instructionOrQuestion!; } - // TODO: To what extent do we want to enforce that the additional questions are also answered? + // TODO: To what extent do we want to enforce that the additional questions are also answered? return requiredQuestionsAnswered; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs index 4470c4ee9a..6ae123c55d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Specialized.Magentic; @@ -27,12 +26,9 @@ namespace Microsoft.Agents.AI.Workflows; /// not supported on the ManagerAgent. /// /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] -public class MagenticWorkflowBuilder(AIAgent managerAgent) +public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilderBase { private readonly List _team = new(); - private string? _name; - private string? _description; private int _maxStalls = TaskLimits.DefaultMaxStallCount; private int? _maxRounds; private int? _maxResets; @@ -45,20 +41,6 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) return this; } - /// - public MagenticWorkflowBuilder WithName(string name) - { - this._name = name; - return this; - } - - /// - public MagenticWorkflowBuilder WithDescription(string description) - { - this._description = description; - return this; - } - /// /// Set the maximum number of coordination rounds. means unlimited. /// @@ -115,28 +97,29 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) ForwardIncomingMessages = false }; + Dictionary teamMap = new(AIAgentIDEqualityComparer.Instance); List teamBindings = []; foreach (AIAgent agent in team) { ExecutorBinding binding = agent.BindAsExecutor(options); teamBindings.Add(binding); + teamMap[agent] = binding; result.AddEdge(binding, orchestrator); } - result.AddFanOutEdge(orchestrator, teamBindings) - .WithOutputFrom(orchestrator); + result.AddFanOutEdge(orchestrator, teamBindings); - if (!string.IsNullOrWhiteSpace(this._name)) + this.ApplyOutputDesignations(result, teamMap, "Magentic", () => { - result.WithName(this._name); - } - - if (!string.IsNullOrWhiteSpace(this._description)) - { - result.WithDescription(this._description); - } + result.WithOutputFrom(orchestrator); + if (teamMap.Count > 0) + { + result.WithIntermediateOutputFrom([.. teamMap.Values]); + } + }); + this.ApplyMetadata(result); return result; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/OrchestrationBuilderBase.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/OrchestrationBuilderBase.cs new file mode 100644 index 0000000000..6d44c03509 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/OrchestrationBuilderBase.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Common fluent surface shared by every orchestration-style workflow builder: +/// human-readable name + description, and the +/// / output-designation +/// pair with memoized defaults-suppression semantics. +/// +/// The concrete builder type, for fluent self-return. +public abstract class OrchestrationBuilderBase + where TBuilder : OrchestrationBuilderBase +{ + /// Optional workflow name; applied to the inner at Build(). + protected string? Name { get; private set; } + + /// Optional workflow description; applied to the inner at Build(). + protected string? Description { get; private set; } + + /// + /// Memoized output designations. means the user has not made any + /// explicit designation, and the orchestration-specific defaults will be applied at + /// Build() time. A non- (possibly empty) map means the user took + /// control and only these designations will be replayed onto the inner + /// . An entry's value is the set of tags requested for the + /// agent — an empty set encodes a terminal-only designation. + /// + protected Dictionary>? OutputDesignations { get; private set; } + + /// Sets the human-readable name for the workflow. + public TBuilder WithName(string name) + { + this.Name = name; + return (TBuilder)this; + } + + /// Sets the description for the workflow. + public TBuilder WithDescription(string description) + { + this.Description = description; + return (TBuilder)this; + } + + /// + /// Designates the given as sources of terminal workflow output. + /// Calling any output-designation method (this or ) + /// suppresses the orchestration-specific defaults: only the user-specified designations + /// reach the inner . + /// + public TBuilder WithOutputFrom(params IEnumerable agents) + { + Throw.IfNull(agents); + this.OutputDesignations ??= new(AIAgentIDEqualityComparer.Instance); + foreach (AIAgent agent in agents) + { + Throw.IfNull(agent, nameof(agents)); + if (!this.OutputDesignations.ContainsKey(agent)) + { + this.OutputDesignations[agent] = []; + } + } + return (TBuilder)this; + } + + /// + /// Designates the given as sources of intermediate workflow + /// output. See for the defaults-suppression semantics. + /// + public TBuilder WithIntermediateOutputFrom(IEnumerable agents) + { + Throw.IfNull(agents); + this.OutputDesignations ??= new(AIAgentIDEqualityComparer.Instance); + foreach (AIAgent agent in agents) + { + Throw.IfNull(agent, nameof(agents)); + if (!this.OutputDesignations.TryGetValue(agent, out HashSet? tags)) + { + tags = []; + this.OutputDesignations[agent] = tags; + } + tags.Add(OutputTag.Intermediate); + } + return (TBuilder)this; + } + + /// + /// Applies the optional and to . + /// Subclasses should call this from their Build() implementation. + /// + protected void ApplyMetadata(WorkflowBuilder builder) + { + Throw.IfNull(builder); + if (!string.IsNullOrWhiteSpace(this.Name)) + { + builder.WithName(this.Name!); + } + if (!string.IsNullOrWhiteSpace(this.Description)) + { + builder.WithDescription(this.Description!); + } + } + + /// + /// Applies the user's memoized output designations to , or invokes + /// if the user made no explicit designation. + /// + /// The inner . + /// Map from participating to its bound executor. + /// Used in the not-a-participant error message (e.g. "sequential", "group chat"). + /// Action invoked when no explicit designation was made. + protected void ApplyOutputDesignations( + WorkflowBuilder builder, + IReadOnlyDictionary agentMap, + string orchestrationKind, + Action applyDefaults) + { + Throw.IfNull(builder); + Throw.IfNull(agentMap); + Throw.IfNull(applyDefaults); + + if (this.OutputDesignations is null) + { + applyDefaults(); + return; + } + + foreach (AIAgent agent in this.OutputDesignations.Keys) + { + if (!agentMap.TryGetValue(agent, out ExecutorBinding? binding)) + { + throw new InvalidOperationException( + $"Output designation references agent '{agent.Name ?? agent.Id}', which is not a participant in this {orchestrationKind} workflow."); + } + + HashSet tags = this.OutputDesignations[agent]; + if (tags.Count == 0) + { + builder.WithOutputFrom(binding); + } + else + { + foreach (OutputTag tag in tags) + { + builder.WithOutputFrom(binding, tag); + } + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs new file mode 100644 index 0000000000..073e2f995f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Identifies the kind of output that a represents. +/// A thin ChatRole-style wrapper around a normalized string , +/// with value equality and a closed set of well-known singletons (the constructor is +/// for now). +/// +[JsonConverter(typeof(OutputTagJsonConverter))] +public readonly struct OutputTag : IEquatable +{ + /// + /// The string identifier of the tag. Compared with ordinal equality. + /// + public string? Value { get; } + + internal OutputTag(string value) + { + this.Value = Throw.IfNullOrEmpty(value); + } + + /// + /// The tag denoting an intermediate workflow output — emitted by executors + /// registered via . + /// Terminal (non-intermediate) outputs carry no tag. + /// + public static OutputTag Intermediate { get; } = new("intermediate"); + + /// + public bool Equals(OutputTag other) => string.Equals(this.Value, other.Value, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is OutputTag other && this.Equals(other); + + /// + public override int GetHashCode() => this.Value is null ? 0 : StringComparer.Ordinal.GetHashCode(this.Value); + + /// Determines whether two values are equal. + public static bool operator ==(OutputTag left, OutputTag right) => left.Equals(right); + + /// Determines whether two values are not equal. + public static bool operator !=(OutputTag left, OutputTag right) => !left.Equals(right); + + /// + public override string ToString() => this.Value ?? string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs new file mode 100644 index 0000000000..4b19e3b2cb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// JSON converter for that round-trips the underlying +/// as a bare JSON string. +/// +internal sealed class OutputTagJsonConverter : JsonConverter +{ + public override OutputTag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? value = reader.GetString(); + if (string.IsNullOrEmpty(value)) + { + return default; + } + + // Reuse the well-known singleton where possible so callers can do reference + // comparisons on the common case without paying the extra allocation cost. + if (string.Equals(value, OutputTag.Intermediate.Value, StringComparison.Ordinal)) + { + return OutputTag.Intermediate; + } + + return new OutputTag(value!); + } + + public override void Write(Utf8JsonWriter writer, OutputTag value, JsonSerializerOptions options) + { + if (value.Value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value.Value); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs index 8f11fe7ed6..cd849cbf15 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs @@ -69,4 +69,23 @@ public class RoundRobinGroupChatManager : GroupChatManager base.Reset(); this._nextIndex = 0; } + + /// + protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + => context.QueueStateUpdateAsync(StateKey, new RoundRobinGroupChatManagerState(this._nextIndex), cancellationToken: cancellationToken); + + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + RoundRobinGroupChatManagerState? state = await context.ReadStateAsync(StateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + this._nextIndex = state?.NextIndex ?? 0; + if (this._nextIndex < 0 || this._nextIndex >= this._agents.Count) + { + this._nextIndex = 0; + } + } + + private const string StateKey = "next_index"; } + +internal sealed record RoundRobinGroupChatManagerState(int NextIndex); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs new file mode 100644 index 0000000000..cfe1d1e1d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SequentialWorkflowBuilder.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Fluent builder for sequential agent workflows: a pipeline where the output of one +/// agent is the input to the next, terminating in an aggregator that yields the +/// accumulated s as the workflow output. +/// +/// +/// 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 +/// is designated as an intermediate output source. Calling +/// +/// or +/// at all suppresses these defaults. +/// +public sealed class SequentialWorkflowBuilder : OrchestrationBuilderBase +{ + private readonly List _agents = []; + + /// + /// Initializes a new with the given pipeline + /// of . + /// + public SequentialWorkflowBuilder(params IEnumerable agents) + { + Throw.IfNull(agents); + foreach (AIAgent agent in agents) + { + Throw.IfNull(agent, nameof(agents)); + this._agents.Add(agent); + } + } + + /// Builds the configured sequential workflow. + public Workflow Build() + { + if (this._agents.Count == 0) + { + throw new ArgumentException("At least one agent must be provided to the SequentialWorkflowBuilder.", "agents"); + } + + AIAgentHostOptions options = new() + { + ReassignOtherAgentsAsUsers = true, + ForwardIncomingMessages = true, + }; + + Dictionary agentMap = new(AIAgentIDEqualityComparer.Instance); + List agentExecutors = new(this._agents.Count); + foreach (AIAgent agent in this._agents) + { + ExecutorBinding binding = agent.BindAsExecutor(options); + agentExecutors.Add(binding); + agentMap[agent] = binding; + } + + 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.AddEdge(previous, end).BindExecutor(end); + + this.ApplyMetadata(builder); + this.ApplyOutputDesignations(builder, agentMap, "sequential", () => + { + builder.WithOutputFrom(end); + builder.WithIntermediateOutputFrom(agentExecutors); + }); + + return builder.Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs index b902bf8ef1..a3f206fc1e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs @@ -20,12 +20,25 @@ internal sealed class GroupChatHost( AutoSendTurnToken = false }; + private const string HistoryStateKey = nameof(_history); + private const string CurrentSpeakerStateKey = nameof(_currentSpeakerExecutorId); + private readonly AIAgent[] _agents = agents; private readonly Dictionary _agentMap = agentMap; private readonly Func, GroupChatManager> _managerFactory = managerFactory; private GroupChatManager? _manager; + // Canonical conversation accumulated across turns. Each participant maintains its own per-agent + // session/thread; the host keeps this only as the source of truth for the manager hooks + // (SelectNextAgentAsync / ShouldTerminateAsync) and for the workflow's final output. + private List _history = []; + + // Executor id of the participant we most recently dispatched a TurnToken to – i.e., the current + // speaker whose response is about to arrive. Used to exclude that participant from the next + // broadcast (its own session already contains the message it produced). + private string? _currentSpeakerExecutorId; + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => base.ConfigureProtocol(protocolBuilder).YieldsOutput>(); @@ -33,30 +46,105 @@ internal sealed class GroupChatHost( { this._manager ??= this._managerFactory(this._agents); - if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false)) + // The delta arriving here is either the initial user input (turn 0) or the most recent speaker's + // response (subsequent turns) – participants no longer echo incoming messages back to the host. + if (messages.Count > 0) { - var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false); - messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered]; + this._history.AddRange(messages); + } - if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent && - this._agentMap.TryGetValue(nextAgent, out var executor)) + if (await this._manager.ShouldTerminateAsync(this._history, cancellationToken).ConfigureAwait(false)) + { + await this.CompleteAsync(context, cancellationToken).ConfigureAwait(false); + return; + } + + if (messages.Count > 0) + { + IEnumerable filteredDelta = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false); + List broadcastMessages = filteredDelta is null + ? messages + : (ReferenceEquals(filteredDelta, messages) ? messages : [.. filteredDelta]); + + if (broadcastMessages.Count > 0) { - this._manager.IterationCount++; - await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false); - await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false); - return; + await this.BroadcastAsync(broadcastMessages, context, cancellationToken).ConfigureAwait(false); } } - this._manager = null; - await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false); + if (await this._manager.SelectNextAgentAsync(this._history, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent && + this._agentMap.TryGetValue(nextAgent, out ExecutorBinding? executor)) + { + this._manager.IterationCount++; + this._currentSpeakerExecutorId = executor.Id; + await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false); + return; + } + + await this.CompleteAsync(context, cancellationToken).ConfigureAwait(false); } + + private ValueTask BroadcastAsync(List messages, IWorkflowContext context, CancellationToken cancellationToken) + { + List? sendTasks = null; + foreach (ExecutorBinding participant in this._agentMap.Values) + { + if (string.Equals(participant.Id, this._currentSpeakerExecutorId, StringComparison.Ordinal)) + { + continue; + } + + (sendTasks ??= []).Add(context.SendMessageAsync(messages, participant.Id, cancellationToken).AsTask()); + } + + return sendTasks is null ? default : new ValueTask(Task.WhenAll(sendTasks)); + } + + private async ValueTask CompleteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + List output = this._history; + this._history = []; + this._currentSpeakerExecutorId = null; + this._manager = null; + + await context.YieldOutputAsync(output, cancellationToken).ConfigureAwait(false); + } + protected override ValueTask ResetAsync() { this._manager = null; + this._history = []; + this._currentSpeakerExecutorId = null; return base.ResetAsync(); } ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); + + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task historyTask = context.QueueStateUpdateAsync(HistoryStateKey, this._history, cancellationToken: cancellationToken).AsTask(); + Task currentSpeakerTask = context.QueueStateUpdateAsync(CurrentSpeakerStateKey, this._currentSpeakerExecutorId, cancellationToken: cancellationToken).AsTask(); + Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask(); + + // Eagerly materialize the manager so subclass state (e.g., the round-robin cursor) gets + // persisted on every checkpoint, even if no turn has been taken yet since the host was constructed. + this._manager ??= this._managerFactory(this._agents); + Task managerTask = this._manager.CheckpointAsync(context, cancellationToken).AsTask(); + + await Task.WhenAll(historyTask, currentSpeakerTask, baseTask, managerTask).ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + this._history = await context.ReadStateAsync>(HistoryStateKey, cancellationToken: cancellationToken).ConfigureAwait(false) ?? []; + this._currentSpeakerExecutorId = await context.ReadStateAsync(CurrentSpeakerStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Instantiate the manager eagerly so its restore hook can rehydrate IterationCount and any + // subclass-defined state (e.g., RoundRobinGroupChatManager._nextIndex). + this._manager = this._managerFactory(this._agents); + await this._manager.RestoreCheckpointAsync(context, cancellationToken).ConfigureAwait(false); + + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 87c67c81c2..914a96bfbe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -30,6 +30,17 @@ internal sealed class HandoffAgentExecutorOptions public bool? EmitAgentResponseUpdateEvents { get; set; } public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; + + // Termination condition. When provided, evaluated after the agent responds and no handoff was + // requested. If it returns true, the outgoing HandoffState is stamped with IsTerminated = true + // so the per-agent routing switch routes the turn to HandoffEndExecutor instead of continuing. + public Func, ValueTask>? TerminationCondition { get; set; } +} + +internal static class HandoffWorkflowBuilderDefaults +{ + public const string DefaultAutonomousContinuationPrompt = "User did not respond. Continue assisting autonomously."; + public const int DefaultAutonomousTurnLimit = 50; } internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) @@ -70,7 +81,6 @@ internal sealed record StateRef(string Key, string? ScopeName) } /// Executor used to represent an agent in a handoffs workflow, responding to events. -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] internal sealed class HandoffAgentExecutor : StatefulExecutor { @@ -250,6 +260,7 @@ internal sealed class HandoffAgentExecutor : } int newConversationBookmark = state.ConversationBookmark; + List? conversationSnapshot = null; await this._sharedStateRef.InvokeWithStateAsync( (sharedState, ctx, ct) => { @@ -285,12 +296,25 @@ internal sealed class HandoffAgentExecutor : } _ = sharedState.Conversation.AddMessage(handoffCallResultMessage); + + // Reset this agent's autonomous-turn counter when it chooses to hand off, so that + // if control returns to this agent later in the turn (e.g. via another handoff), + // its autonomous loop starts fresh rather than carrying over prior iterations. + sharedState.AutonomousTurnsByAgent[this._agent.Id] = 0; } else { newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages); } + // Snapshot the conversation for termination evaluation while we still hold shared state access. + // Termination is only relevant when no handoff was requested — a requested handoff always + // routes to the target agent regardless of termination. + if (this._options.TerminationCondition is not null && !result.IsHandoffRequested) + { + conversationSnapshot = sharedState.Conversation.CloneHistory(); + } + return new ValueTask(); }, context, @@ -298,18 +322,27 @@ internal sealed class HandoffAgentExecutor : // We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only // happens if we have no outstanding requests. - if (!this.HasOutstandingRequests) + if (this.HasOutstandingRequests) { - HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id); - - await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); - - // reset the state for the next handoff, making sure to keep track of the conversation bookmark, and avoid resetting the - // agent session. (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which can be a bit confusing.) - return state with { IncomingState = null, ConversationBookmark = newConversationBookmark }; + return state with { ConversationBookmark = newConversationBookmark }; } - return state; + // Evaluate the termination condition (when configured and no handoff was requested) and stamp + // the result onto the outgoing HandoffState so the per-agent routing switch can route the turn + // to HandoffEndExecutor instead of dispatching another handoff or autonomous continuation. + bool isTerminated = false; + if (conversationSnapshot is not null) + { + isTerminated = await this._options.TerminationCondition!(conversationSnapshot).ConfigureAwait(false); + } + + HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id, isTerminated); + + await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); + + // Reset the turn-local state; keep the conversation bookmark and the agent session so the + // next invocation (handoff back, autonomous loop-back, or new user turn) resumes cleanly. + return state with { IncomingState = null, ConversationBookmark = newConversationBookmark }; } public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs index edcc92d1c8..125a5a9475 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs @@ -8,18 +8,76 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; -/// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +/// Executor used at the end of a handoff workflow to raise a final completed event, +/// and in autonomous mode to loop control back to the source agent. +/// +/// Autonomous-turn counters are tracked per source agent in . +/// On each invocation where the source agent did not request a handoff and termination has not fired, +/// the counter for that agent is incremented and control is sent back to that agent (via the +/// autonomous-return switch wired downstream of this executor). When the counter reaches the per-agent +/// turn limit — or when termination fires, or when autonomous mode is disabled for that agent — the +/// counter is reset to zero and the conversation is yielded as workflow output. +/// +internal sealed class HandoffEndExecutor : Executor, IResettableExecutor { public const string ExecutorId = "HandoffEnd"; + private readonly bool _returnToPrevious; + private readonly bool _autonomousMode; + private readonly int _autonomousTurnLimit; + private readonly string _autonomousContinuationPrompt; + private readonly HashSet? _autonomousEnabledAgentIds; + private readonly IReadOnlyDictionary _autonomousTurnLimitsByAgentId; + private readonly IReadOnlyDictionary _autonomousContinuationPromptsByAgentId; + private readonly StateRef _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey, HandoffConstants.HandoffSharedStateScope); - protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => - protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler( - (handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken))) - .YieldsOutput>(); + public HandoffEndExecutor( + bool returnToPrevious, + bool autonomousMode = false, + int autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit, + string autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt, + HashSet? autonomousEnabledAgentIds = null, + IReadOnlyDictionary? autonomousTurnLimitsByAgentId = null, + IReadOnlyDictionary? autonomousContinuationPromptsByAgentId = null) + : base(ExecutorId, declareCrossRunShareable: true) + { + this._returnToPrevious = returnToPrevious; + this._autonomousMode = autonomousMode; + this._autonomousTurnLimit = autonomousTurnLimit; + this._autonomousContinuationPrompt = autonomousContinuationPrompt; + this._autonomousEnabledAgentIds = autonomousEnabledAgentIds; + this._autonomousTurnLimitsByAgentId = autonomousTurnLimitsByAgentId ?? new Dictionary(); + this._autonomousContinuationPromptsByAgentId = autonomousContinuationPromptsByAgentId ?? new Dictionary(); + } + + private bool IsAutonomousEnabledFor(string agentId) => + // Null allow-list means every participant has autonomous mode enabled. + this._autonomousEnabledAgentIds?.Contains(agentId) ?? true; + + private int TurnLimitFor(string agentId) => + this._autonomousTurnLimitsByAgentId.TryGetValue(agentId, out int limit) ? limit : this._autonomousTurnLimit; + + private string ContinuationPromptFor(string agentId) => + this._autonomousContinuationPromptsByAgentId.TryGetValue(agentId, out string? prompt) ? prompt : this._autonomousContinuationPrompt; + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + { + ProtocolBuilder pb = protocolBuilder + .ConfigureRoutes(routeBuilder => routeBuilder.AddHandler( + (handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken))) + .YieldsOutput>(); + + // Only advertise the outgoing-message capability when autonomous mode is enabled, since the + // downstream return switch (Builder.AddSwitch on End) is only wired in that case. + if (this._autonomousMode) + { + pb = pb.SendsMessage(); + } + + return pb; + } private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken) { @@ -31,7 +89,56 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized."); } - if (returnToPrevious) + // Autonomous mode: when the agent did not request a handoff and termination has not fired, + // loop control back to the same agent (up to that agent's turn limit). Per-agent overrides + // (enabled-agents allow-list, turn limit, continuation prompt) are honored here. + bool canContinueAutonomously = this._autonomousMode + && !handoff.IsTerminated + && handoff.RequestedHandoffTargetAgentId is null + && handoff.PreviousAgentId is not null + && this.IsAutonomousEnabledFor(handoff.PreviousAgentId!); + + if (canContinueAutonomously) + { + string agentId = handoff.PreviousAgentId!; + int turns = sharedState.AutonomousTurnsByAgent.TryGetValue(agentId, out int existing) ? existing : 0; + int limit = this.TurnLimitFor(agentId); + + if (turns < limit) + { + sharedState.AutonomousTurnsByAgent[agentId] = turns + 1; + + // Append a synthetic user message containing the continuation prompt so the agent + // has fresh input to act on for the next autonomous iteration. + sharedState.Conversation.AddMessage(new ChatMessage(ChatRole.User, this.ContinuationPromptFor(agentId)) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + // Send a HandoffState targeting the source agent. The downstream + // HandoffAutonomousReturnSwitch routes it to the matching agent executor. + HandoffState loopBack = new( + handoff.TurnToken, + RequestedHandoffTargetAgentId: agentId, + PreviousAgentId: agentId, + IsTerminated: false); + + await context.SendMessageAsync(loopBack, cancellationToken).ConfigureAwait(false); + + return sharedState; + } + } + + // Terminal path: either termination fired, autonomous mode is disabled, or the turn + // limit is reached. Reset this agent's autonomous counter so a subsequent user turn + // starts fresh, then yield the conversation as workflow output. + if (handoff.PreviousAgentId is not null) + { + sharedState.AutonomousTurnsByAgent[handoff.PreviousAgentId] = 0; + } + + if (this._returnToPrevious) { sharedState.PreviousAgentId = handoff.PreviousAgentId; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs index 61eebc0e2b..2ca73ca3d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs @@ -2,12 +2,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] internal sealed class HandoffMessagesFilter { private readonly HandoffToolCallFilteringBehavior _filteringBehavior; @@ -17,7 +15,6 @@ internal sealed class HandoffMessagesFilter this._filteringBehavior = filteringBehavior; } - [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] internal static bool IsHandoffFunctionName(string name) { return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs index 8915b44aa0..47ef204d86 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs @@ -25,21 +25,32 @@ internal static class HandoffConstants internal sealed class HandoffSharedState { [JsonConstructor] - internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId) + internal HandoffSharedState(MultiPartyConversation conversation, string? previousAgentId, Dictionary? autonomousTurnsByAgent) { this.Conversation = conversation; this.PreviousAgentId = previousAgentId; + this.AutonomousTurnsByAgent = autonomousTurnsByAgent ?? []; } public HandoffSharedState() { this.Conversation = new([]); + this.AutonomousTurnsByAgent = []; } [JsonInclude] public MultiPartyConversation Conversation { get; internal set; } public string? PreviousAgentId { get; set; } + + /// + /// Tracks the number of autonomous-mode continuation iterations consumed by each agent in the current + /// "active" autonomous run. The counter is incremented by each time + /// the End executor loops control back to the source agent in autonomous mode, and reset to 0 once + /// the autonomous loop terminates (limit reached or termination condition fired). + /// + [JsonInclude] + public Dictionary AutonomousTurnsByAgent { get; internal set; } } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. @@ -64,6 +75,10 @@ internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocol sharedState ??= new HandoffSharedState(); sharedState.Conversation.AddMessages(messages); + // Reset all autonomous-mode counters at the start of every fresh user turn so that a + // prior turn's counters cannot prematurely terminate the new turn's autonomous loop. + sharedState.AutonomousTurnsByAgent.Clear(); + string? previousAgentId = sharedState.PreviousAgentId; // If we are configured to return to the previous agent, include the previous agent id in the handoff state. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index 24cf788cb8..dad7848b47 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -5,4 +5,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, string? RequestedHandoffTargetAgentId, - string? PreviousAgentId = null); + string? PreviousAgentId = null, + bool IsTerminated = false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs index 30a93c6850..31e80d8725 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/MagenticOrchestrator.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -18,7 +17,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic; [JsonDerivedType(typeof(MagenticPlanCreatedEvent))] [JsonDerivedType(typeof(MagenticReplannedEvent))] [JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))] -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data) { } @@ -27,7 +25,6 @@ public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(da /// Represents the creation of the initial plan /// /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger) { /// @@ -40,7 +37,6 @@ public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : Magent /// Represents the creation of a new plan in response to a stall. /// /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger) { /// @@ -53,7 +49,6 @@ public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : Magentic /// Represents an update to the when running a coordination round. /// /// -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger) { /// @@ -138,7 +133,6 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta to the conversation and enters the inner loop. - If revision requested, append the review comments to the chat history, trigger replanning via the manager, emit a REPLANNED event, then run the outer loop. - */ if (this._taskContext == null || this._taskContext.TaskLedger == null) { @@ -201,7 +195,12 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List team, Ta } else { - // Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan) + // Subsequent turns: agent returned control, go directly to coordination (progress ledger only, no replan). + // Capture the participant's reply into the manager-visible chat history so the progress ledger can see it. + if (messages is { Count: > 0 }) + { + this._taskContext.ChatHistory.AddRange(messages); + } await this.RunCoordinationRoundAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index eff1cfb9a3..03c8f6a920 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -24,7 +24,7 @@ public class Workflow internal Dictionary ExecutorBindings { get; init; } = []; internal Dictionary> Edges { get; init; } = []; - internal HashSet OutputExecutors { get; init; } = []; + internal Dictionary> OutputExecutors { get; init; } = new(StringComparer.Ordinal); /// /// Gets the collection of edges grouped by their source node identifier. @@ -221,7 +221,7 @@ public class Workflow startExecutor.AttachRequestContext(new NoOpExternalRequestContext()); ProtocolDescriptor inputProtocol = startExecutor.DescribeProtocol(); - IEnumerable> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask()); + IEnumerable> outputExecutorTasks = this.OutputExecutors.Keys.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask()); Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false); IEnumerable yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index e29abca5ab..3acb0c3f61 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -33,7 +33,7 @@ public class WorkflowBuilder private readonly HashSet _unboundExecutors = []; private readonly HashSet _conditionlessConnections = []; private readonly Dictionary _requestPorts = []; - private readonly HashSet _outputExecutors = []; + private readonly Dictionary> _outputExecutors = new(StringComparer.Ordinal); private readonly string _startExecutorId; private string? _name; @@ -97,22 +97,89 @@ public class WorkflowBuilder } /// - /// Register executors as an output source. Executors can use to yield output values. - /// By default, message handlers with a non-void return type will also be yielded, unless - /// is set to . + /// Register executors as a source of terminal workflow outputs. Executors can use + /// to yield output values; yielded values from + /// registered executors are surfaced as (or one of its + /// subclasses) with an empty set. + /// By default, message handlers with a non-void return type will also be yielded, unless + /// is set to . /// - /// - /// + /// + /// AIAgent payloads ( / ) only + /// participate in this designation when + /// is + /// ; otherwise they are emitted unconditionally and untagged. + /// + /// The executors to register as output sources. + /// The current instance, enabling fluent configuration. public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors) { foreach (ExecutorBinding executor in executors) { - this._outputExecutors.Add(this.Track(executor).Id); + this.EnsureOutputExecutor(this.Track(executor).Id); } return this; } + /// + /// Register executors as a source of workflow outputs carrying the given . + /// Tags accumulate across repeated calls; the registered id always exists with the union of all + /// tags applied across all calls (and an empty set if only the untagged + /// overload was used). + /// + /// + /// Forward-looking surface for when the constructor opens to + /// user-defined tags. Today, prefer + /// + /// for the case. + /// + /// The executors to register. + /// The tag to apply to events yielded by the listed executors. + /// The current instance, enabling fluent configuration. + public WorkflowBuilder WithOutputFrom(IEnumerable executors, OutputTag tag) + { + Throw.IfNull(executors); + + foreach (ExecutorBinding executor in executors) + { + this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag); + } + + return this; + } + + /// + /// Register a single executor as a source of workflow outputs carrying the given . + /// Convenience overload for the single-executor case; equivalent to passing a one-element sequence + /// to . + /// + /// The executor to register. + /// The tag to apply to events yielded by the executor. + /// The current instance, enabling fluent configuration. + public WorkflowBuilder WithOutputFrom(ExecutorBinding executor, OutputTag tag) + { + Throw.IfNull(executor); + + this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag); + + return this; + } + + /// + /// Ensures the executor id is present in ; if newly added, + /// initializes with an empty tag set. Returns the tag set for the id (mutable). + /// + private HashSet EnsureOutputExecutor(string executorId) + { + if (!this._outputExecutors.TryGetValue(executorId, out HashSet? tags)) + { + tags = []; + this._outputExecutors[executorId] = tags; + } + return tags; + } + /// /// Sets the human-readable name for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index a22aa8e722..6db047255d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -211,4 +211,28 @@ public static class WorkflowBuilderExtensions return switchBuilder.ReduceToFanOut(builder, source); } + + /// + /// Register executors as a source of intermediate workflow outputs. The resulting + /// s carry in their + /// set, and + /// returns + /// . Use this for progress updates, partial results, and other + /// non-terminal emissions that downstream consumers (DevUI, logging, Workflow-as-Agent + /// surfaces) should see distinctly from the workflow's final output. + /// + /// + /// AIAgent payloads ( / ) only + /// participate in this designation when + /// is + /// ; otherwise they bypass the filter and are emitted untagged. + /// + /// The workflow builder to register executors on. + /// The executors to register as intermediate output sources. + /// The , enabling fluent configuration. + public static WorkflowBuilder WithIntermediateOutputFrom(this WorkflowBuilder builder, IEnumerable executors) + { + Throw.IfNull(builder); + return builder.WithOutputFrom(executors, OutputTag.Intermediate); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs index f0fe884f6d..380baa22d2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; @@ -13,14 +14,39 @@ namespace Microsoft.Agents.AI.Workflows; [JsonDerivedType(typeof(AgentResponseUpdateEvent))] public class WorkflowOutputEvent : WorkflowEvent { + private readonly HashSet _tags; + /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with no tags. /// /// The output data. /// The identifier of the executor that yielded this output. - public WorkflowOutputEvent(object data, string executorId) : base(data) + public WorkflowOutputEvent(object data, string executorId) : this(data, executorId, tags: null) + { + } + + /// + /// Initializes a new instance of the class carrying the + /// given output tag. + /// + /// The output data. + /// The identifier of the executor that yielded this output. + /// The single output tag to associate with this event. + public WorkflowOutputEvent(object data, string executorId, OutputTag tag) : this(data, executorId, tags: new[] { tag }) + { + } + + /// + /// Initializes a new instance of the class carrying the + /// given output tags (deduplicated). + /// + /// The output data. + /// The identifier of the executor that yielded this output. + /// The output tags to associate with this event. May be or empty (the event is then untagged). + public WorkflowOutputEvent(object data, string executorId, IEnumerable? tags) : base(data) { this.ExecutorId = executorId; + this._tags = tags is null ? new HashSet() : new HashSet(tags); } /// @@ -32,8 +58,21 @@ public class WorkflowOutputEvent : WorkflowEvent /// The unique identifier of the executor that yielded this output. /// [Obsolete("Use ExecutorId instead.")] + [JsonIgnore] public string SourceId => this.ExecutorId; + /// + /// The set of output tags associated with this event. Never ; + /// empty for terminal/regular outputs. The presence of + /// marks this event as an intermediate output. + /// + public IEnumerable Tags => this._tags; + + /// + /// Returns if this event carries the given tag. + /// + public bool HasTag(OutputTag tag) => this._tags.Contains(tag); + /// /// Determines whether the underlying data is of the specified type or a derived type. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs new file mode 100644 index 0000000000..1bbad70f87 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Extension helpers for inspecting tag membership. +/// +public static class WorkflowOutputEventExtensions +{ + /// + /// Returns if the event carries + /// in its . + /// + public static bool IsIntermediate(this WorkflowOutputEvent evt) + { + Throw.IfNull(evt); + return evt.HasTag(OutputTag.Intermediate); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 719b72e112..812bda1150 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -520,11 +520,20 @@ internal sealed class WorkflowSession : AgentSession goto default; case AgentResponseEvent agentResponse: - if (!this._includeWorkflowOutputsInResponse) + // Under Futures.EnableAgentResponseOutputTaggingAndFiltering=true, mirror + // AgentResponseUpdateEvent's behavior: always forward, regardless of the + // _includeWorkflowOutputsInResponse host flag / "intermediate" tag. Under + // the legacy default, keep today's behavior — gated by the include flag. + if (!Futures.EnableAgentResponseOutputTaggingAndFiltering && !this._includeWorkflowOutputsInResponse) { goto default; } + // Either EnableAgentResponseOutputTaggingAndFiltering -- so yield the Response + // regardless of whether it is tagged "intermediate" or whether the + // _includeWorkflowOutputInResponse flag is set. Reason being: The user specifies + // exclusion of an event by enabling filtering and then _not_ marking an Executor + // as an output executor. foreach (ChatMessage message in agentResponse.Response.Messages) { yield return this.CreateUpdate(this.LastResponseId, evt, message); @@ -539,7 +548,11 @@ internal sealed class WorkflowSession : AgentSession _ => null }; - if (!this._includeWorkflowOutputsInResponse || updateMessages == null) + // Same assymetry as with AgentResponseEvent, but there is no EnableFiltering flag + // to consider. If this made it here (and since it is not an AgentResponse[Update]), + // it means it is already been selected as an Output() from the user. Intermediate + // is irrelevant here. + if (updateMessages == null || !this._includeWorkflowOutputsInResponse) { goto default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index 8b3d3e4ce8..0614aca36d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -80,9 +80,8 @@ internal static partial class WorkflowsJsonUtilities [JsonSerializable(typeof(ExecutorIdentity))] [JsonSerializable(typeof(RunnerStateData))] - // Workflow Representation Types - [JsonSerializable(typeof(WorkflowInfo))] - [JsonSerializable(typeof(EdgeConnection))] + // Workflow Output Types + [JsonSerializable(typeof(OutputTag))] // Workflow-as-Agent [JsonSerializable(typeof(WorkflowChatHistoryProvider.StoreState))] @@ -101,6 +100,8 @@ internal static partial class WorkflowsJsonUtilities [JsonSerializable(typeof(MagenticPlanReviewRequest))] [JsonSerializable(typeof(MagenticPlanReviewResponse))] [JsonSerializable(typeof(MagenticTaskState))] + [JsonSerializable(typeof(GroupChatManagerState))] + [JsonSerializable(typeof(RoundRobinGroupChatManagerState))] [JsonSerializable(typeof(ResetChatSignal))] // Event Types diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs index d1c792de21..8a74fa034e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -49,14 +49,13 @@ public sealed class AgentFileSkill : AgentSkill /// /// /// Returns the raw SKILL.md content. When the skill has scripts, a - /// <scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts> - /// block is appended with a per-script entry describing the expected argument format. + /// <script_schemas> block is appended describing the argument format. /// The result is cached after the first access. /// public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) { var content = this._content ??= this._scripts is { Count: > 0 } - ? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts) + ? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts) : this._originalContent; return new(content); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillFilterContext.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillFilterContext.cs new file mode 100644 index 0000000000..34937baed5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillFilterContext.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides contextual information about a discovered file to the +/// and +/// predicates. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillFilterContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the skill (from SKILL.md frontmatter). + /// + /// The path to the script or resource file relative to the skill directory (using forward slashes). + /// + internal AgentFileSkillFilterContext(string skillName, string relativeFilePath) + { + this.SkillName = Throw.IfNullOrWhitespace(skillName); + this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath); + } + + /// + /// Gets the name of the skill as declared in the SKILL.md frontmatter. + /// + /// unit-converter + public string SkillName { get; } + + /// + /// Gets the path to the script or resource file relative to the skill directory (using forward slashes). + /// For root-level files this is just the filename; for nested files it includes the subdirectory. + /// + /// + /// run.py for a script at skill root, + /// scripts/convert.js for a nested script, or + /// references/guide.md for a nested resource. + /// + public string RelativeFilePath { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index d31501426e..54a5dec10c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -30,18 +30,12 @@ namespace Microsoft.Agents.AI; internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; - private const int MaxSearchDepth = 2; - - // "." means the skill directory root itself (no subdirectory descent constraint) - private const string RootDirectoryIndicator = "."; + private const int DefaultSearchDepth = 2; + private const int MaxSkillDirectorySearchDepth = 2; private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; - // Standard subdirectory names per https://agentskills.io/specification#directory-structure - private static readonly string[] s_defaultScriptDirectories = ["scripts"]; - private static readonly string[] s_defaultResourceDirectories = ["references", "assets"]; - // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. @@ -63,8 +57,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; - private readonly IReadOnlyList _scriptDirectories; - private readonly IReadOnlyList _resourceDirectories; + private readonly int _searchDepth; + private readonly Func? _scriptFilter; + private readonly Func? _resourceFilter; private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; @@ -111,13 +106,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource options?.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); - this._scriptDirectories = options?.ScriptDirectories is not null - ? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)] - : s_defaultScriptDirectories; - - this._resourceDirectories = options?.ResourceDirectories is not null - ? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)] - : s_defaultResourceDirectories; + this._searchDepth = Throw.IfLessThan(options?.SearchDepth ?? DefaultSearchDepth, 1); + this._scriptFilter = options?.ScriptFilter; + this._resourceFilter = options?.ResourceFilter; this._scriptRunner = scriptRunner; } @@ -174,7 +165,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource results.Add(Path.GetFullPath(directory)); } - if (currentDepth >= MaxSearchDepth) + if (currentDepth >= MaxSkillDirectorySearchDepth) { return; } @@ -305,216 +296,246 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Scans configured resource directories within a skill directory for resource files matching the configured extensions. + /// Scans the skill directory recursively (up to the configured search depth) for resource files + /// matching the configured extensions. /// /// - /// By default, scans references/ and assets/ subdirectories as specified by the - /// Agent Skills specification. - /// Configure to scan different or - /// additional directories, including "." for the skill root itself. /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. + /// If a predicate is configured, files + /// that do not satisfy it are excluded. /// private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { var resources = new List(); - foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) - { - bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); - - // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootDirectory - ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; - - if (!Directory.Exists(targetDirectory)) - { - continue; - } - - // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root directory is excluded — it's a caller-supplied - // trusted path, and the security boundary guards files within it, not the path itself. - if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) - { - LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); - } - - continue; - } - -#if NET - var enumerationOptions = new EnumerationOptions - { - RecurseSubdirectories = false, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - }; - - foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) -#else - foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) -#endif - { - string fileName = Path.GetFileName(filePath); - - // Exclude SKILL.md itself - if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // Filter by extension - string extension = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension)) - { - if (this._logger.IsEnabled(LogLevel.Debug)) - { - LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); - } - - continue; - } - - // Normalize the enumerated path to guard against non-canonical forms. - // e.g. "references/../../../etc/shadow" → "/etc/shadow" - string resolvedFilePath = Path.GetFullPath(filePath); - - // Path containment: reject if the resolved path escapes the target directory. - // e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip - if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) - { - LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); - } - - continue; - } - - // Per-file symlink check: detects if the file (or any intermediate segment) - // is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow" - if (HasSymlinkInPath(resolvedFilePath, targetDirectory)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) - { - LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); - } - - continue; - } - - // Compute relative path and normalize separators. - // e.g. "/skills/myskill/references/guide.md" → "references/guide.md" - string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length)); - - resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); - } - } + this.ScanDirectoryForResources(skillDirectoryFullPath, skillDirectoryFullPath, skillName, resources, currentDepth: 1); return resources; } + private void ScanDirectoryForResources(string targetDirectory, string skillDirectoryFullPath, string skillName, List resources, int currentDepth) + { + if (currentDepth > this._searchDepth) + { + return; + } + + bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase); + + // Directory-level symlink check: skip if targetDirectory (or any intermediate + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied + // trusted path, and the security boundary guards files within it, not the path itself. + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory)); + } + + return; + } + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) +#endif + { + string fileName = Path.GetFileName(filePath); + + // Exclude SKILL.md itself + if (string.Equals(fileName, SkillFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedResourceExtensions.Contains(extension)) + { + if (this._logger.IsEnabled(LogLevel.Debug)) + { + LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), string.IsNullOrEmpty(extension) ? "(none)" : extension); + } + + continue; + } + + // Normalize the enumerated path to guard against non-canonical forms. + // e.g. "references/../../../etc/shadow" → "/etc/shadow" + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment: reject if the resolved path escapes the skill directory. + // e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip + if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Per-file symlink check: detects if the file (or any intermediate segment) + // is a reparse point. e.g. "references/secret.md" → symlink to "/etc/shadow" + if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Compute relative path and normalize separators. + // e.g. "/skills/myskill/references/guide.md" → "references/guide.md" + string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length)); + + // Apply user-provided filter predicate + if (this._resourceFilter is not null && !this._resourceFilter(new AgentFileSkillFilterContext(skillName, relativePath))) + { + continue; + } + + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); + } + + // Recurse into subdirectories if within depth limit + if (currentDepth < this._searchDepth) + { +#if NET + foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions)) +#else + foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory)) +#endif + { + this.ScanDirectoryForResources(subdirectory, skillDirectoryFullPath, skillName, resources, currentDepth + 1); + } + } + } + /// - /// Scans configured script directories within a skill directory for script files matching the configured extensions. + /// Scans the skill directory recursively (up to the configured search depth) for script files + /// matching the configured extensions. /// /// - /// By default, scans the scripts/ subdirectory as specified by the - /// Agent Skills specification. - /// Configure to scan different or - /// additional directories, including "." for the skill root itself. /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. + /// If a predicate is configured, files + /// that do not satisfy it are excluded. /// private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { var scripts = new List(); - foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) + this.ScanDirectoryForScripts(skillDirectoryFullPath, skillDirectoryFullPath, skillName, scripts, currentDepth: 1); + + return scripts; + } + + private void ScanDirectoryForScripts(string targetDirectory, string skillDirectoryFullPath, string skillName, List scripts, int currentDepth) + { + if (currentDepth > this._searchDepth) { - bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); + return; + } - // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootDirectory - ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; + bool isRootDirectory = string.Equals(targetDirectory, skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase); - if (!Directory.Exists(targetDirectory)) + // Directory-level symlink check: skip if targetDirectory (or any intermediate + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied + // trusted path, and the security boundary guards files within it, not the path itself. + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(targetDirectory)); + } + + return; + } + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) +#endif + { + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) { continue; } - // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root directory is excluded — it's a caller-supplied - // trusted path, and the security boundary guards files within it, not the path itself. - if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + // Normalize the enumerated path to guard against non-canonical forms. + // e.g. "scripts/../../../etc/shadow" → "/etc/shadow" + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment: reject if the resolved path escapes the skill directory. + // e.g. "/etc/shadow".StartsWith("/skills/myskill/") → false → skip + if (!resolvedFilePath.StartsWith(skillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); } continue; } -#if NET - var enumerationOptions = new EnumerationOptions + // Per-file symlink check: detects if the file (or any intermediate segment) + // is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow" + if (HasSymlinkInPath(resolvedFilePath, skillDirectoryFullPath)) { - RecurseSubdirectories = false, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - }; - - foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", enumerationOptions)) -#else - foreach (string filePath in Directory.EnumerateFiles(targetDirectory, "*", SearchOption.TopDirectoryOnly)) -#endif - { - // Filter by extension - string extension = Path.GetExtension(filePath); - if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + if (this._logger.IsEnabled(LogLevel.Warning)) { - continue; + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); } - // Normalize the enumerated path to guard against non-canonical forms. - // e.g. "scripts/../../../etc/shadow" → "/etc/shadow" - string resolvedFilePath = Path.GetFullPath(filePath); - - // Path containment: reject if the resolved path escapes the target directory. - // e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip - if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) - { - LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); - } - - continue; - } - - // Per-file symlink check: detects if the file (or any intermediate segment) - // is a reparse point. e.g. "scripts/run.py" → symlink to "/etc/shadow" - if (HasSymlinkInPath(resolvedFilePath, targetDirectory)) - { - if (this._logger.IsEnabled(LogLevel.Warning)) - { - LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); - } - - continue; - } - - // Compute relative path and normalize separators. - // e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py" - string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length)); - - scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + continue; } + + // Compute relative path and normalize separators. + // e.g. "/skills/myskill/scripts/parsepdf.py" → "scripts/parsepdf.py" + string relativePath = NormalizePath(resolvedFilePath.Substring(skillDirectoryFullPath.Length)); + + // Apply user-provided filter predicate + if (this._scriptFilter is not null && !this._scriptFilter(new AgentFileSkillFilterContext(skillName, relativePath))) + { + continue; + } + + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); } - return scripts; + // Recurse into subdirectories if within depth limit + if (currentDepth < this._searchDepth) + { +#if NET + foreach (string subdirectory in Directory.EnumerateDirectories(targetDirectory, "*", enumerationOptions)) +#else + foreach (string subdirectory in this.SafeEnumerateDirectories(targetDirectory)) +#endif + { + this.ScanDirectoryForScripts(subdirectory, skillDirectoryFullPath, skillName, scripts, currentDepth + 1); + } + } } /// @@ -542,6 +563,31 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource return false; } +#if !NET + /// + /// Best-effort directory enumeration for target frameworks without + /// EnumerationOptions.IgnoreInaccessible support. Returns an empty + /// array when the caller lacks permission to read the directory contents, + /// so a single inaccessible child does not abort the entire skill scan. + /// + private string[] SafeEnumerateDirectories(string path) + { + try + { + return Directory.GetDirectories(path); + } + catch (UnauthorizedAccessException) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogDirectoryAccessDenied(this._logger, SanitizePathForLog(path)); + } + + return Array.Empty(); + } + } +#endif + private static string ParseYamlScalarValue(string yamlContent, Match kvMatch) { string value = kvMatch.Groups[3].Value; @@ -664,46 +710,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } } - private static IEnumerable ValidateAndNormalizeDirectoryNames(IEnumerable directories, ILogger logger) - { - foreach (string directory in directories) - { - if (string.IsNullOrWhiteSpace(directory)) - { - throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories)); - } - - // "." is valid — it means the skill root directory. - if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal)) - { - yield return directory; - continue; - } - - // Reject absolute paths and any path segments that escape upward. - if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory)) - { - LogDirectoryNameSkippedInvalid(logger, directory); - continue; - } - - yield return NormalizePath(directory); - } - } - - private static bool ContainsParentTraversalSegment(string directory) - { - foreach (string segment in directory.Split('/', '\\')) - { - if (segment == "..") - { - return true; - } - } - - return false; - } - [LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")] private static partial void LogSkillsDiscovered(ILogger logger, int count); @@ -743,6 +749,6 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource [LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName); - [LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")] - private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName); + [LoggerMessage(LogLevel.Warning, "Skipping directory '{DirectoryPath}': access denied")] + private static partial void LogDirectoryAccessDenied(ILogger logger, string directoryPath); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs index b5c83c0220..c9604e166d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Microsoft.Shared.DiagnosticIds; @@ -32,28 +33,31 @@ public sealed class AgentFileSkillsSourceOptions public IEnumerable? AllowedScriptExtensions { get; set; } /// - /// Gets or sets relative directory paths to scan for script files within each skill directory. - /// Values may be single-segment names (e.g., "scripts") or multi-segment relative - /// paths (e.g., "sub/scripts"). Use "." to include files directly at the - /// skill root. Leading "./" prefixes, trailing separators, and backslashes are - /// normalized automatically; paths containing ".." segments or absolute paths are - /// rejected. - /// When , defaults to scripts (per the - /// Agent Skills specification). - /// When set, replaces the defaults entirely. + /// Gets or sets the maximum depth to search for script and resource files within each skill directory. + /// A value of 1 searches only the skill root directory. A value of 2 searches the root + /// and one level of subdirectories. + /// When , the source uses the default depth of 2. /// - public IEnumerable? ScriptDirectories { get; set; } + /// + /// Must be greater than or equal to 1; lower values are rejected by the constructor. + /// + public int? SearchDepth { get; set; } /// - /// Gets or sets relative directory paths to scan for resource files within each skill directory. - /// Values may be single-segment names (e.g., "references") or multi-segment relative - /// paths (e.g., "sub/resources"). Use "." to include files directly at the - /// skill root. Leading "./" prefixes, trailing separators, and backslashes are - /// normalized automatically; paths containing ".." segments or absolute paths are - /// rejected. - /// When , defaults to references and assets (per the - /// Agent Skills specification). - /// When set, replaces the defaults entirely. + /// Gets or sets a predicate that filters discovered script files. + /// The predicate receives an containing the skill's name + /// and the file's path relative to the skill directory. + /// Return to include the file or to exclude it. + /// When , all scripts matching the allowed extensions are included. /// - public IEnumerable? ResourceDirectories { get; set; } + public Func? ScriptFilter { get; set; } + + /// + /// Gets or sets a predicate that filters discovered resource files. + /// The predicate receives an containing the skill's name + /// and the file's path relative to the skill directory. + /// Return to include the file or to exclude it. + /// When , all resources matching the allowed extensions are included. + /// + public Func? ResourceFilter { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 32f461e32a..84174154a2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -114,7 +114,6 @@ public abstract class AgentClassSkill< this.Frontmatter.Name, this.Frontmatter.Description, this.Instructions, - this.Resources, this.Scripts)); } @@ -147,11 +146,17 @@ public abstract class AgentClassSkill< /// Gets the resources associated with this skill, or if none. /// /// + /// /// The default implementation returns resources discovered via reflection by scanning /// for members annotated with . /// This discovery is compatible with Native AOT because is annotated with /// . The result is cached after the first access. /// Override this property in derived classes to provide skill-specific resources. + /// + /// + /// Resources are not automatically included in the skill body. + /// To enable discovery, reference resources by name in the skill's instructions or in other resources. + /// /// public virtual IReadOnlyList? Resources => this._resources.Value; @@ -159,11 +164,17 @@ public abstract class AgentClassSkill< /// Gets the scripts associated with this skill, or if none. /// /// + /// /// The default implementation returns scripts discovered via reflection by scanning /// for methods annotated with . /// This discovery is compatible with Native AOT because is annotated with /// . The result is cached after the first access. /// Override this property in derived classes to provide skill-specific scripts. + /// + /// + /// Only script parameter schemas are included in the skill body (as a <script_schemas> block). + /// To enable discovery, reference scripts by name in the skill's instructions or in a resource. + /// /// public virtual IReadOnlyList? Scripts => this._scripts.Value; @@ -184,6 +195,10 @@ public abstract class AgentClassSkill< /// /// Creates a skill resource backed by a static value. /// + /// + /// Resources are not automatically included in the skill body. + /// To enable discovery, reference the resource by name in the skill's instructions or in another resource. + /// /// The resource name. /// The static resource value. /// An optional description of the resource. @@ -194,6 +209,10 @@ public abstract class AgentClassSkill< /// /// Creates a skill resource backed by a delegate that produces a dynamic value. /// + /// + /// Resources are not automatically included in the skill body. + /// To enable discovery, reference the resource by name in the skill's instructions or in another resource. + /// /// The resource name. /// A method that produces the resource value when requested. /// An optional description of the resource. @@ -208,6 +227,10 @@ public abstract class AgentClassSkill< /// /// Creates a skill script backed by a delegate. /// + /// + /// Only the script's parameter schema is included in the skill body (as a <script_schemas> block). + /// To enable discovery, reference the script by name in the skill's instructions or in a resource. + /// /// The script name. /// A method to execute when the script is invoked. /// An optional description of the script. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs index 4fb2b045cb..2465431622 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkill.cs @@ -95,7 +95,7 @@ public sealed class AgentInlineSkill : AgentSkill /// public override ValueTask GetContentAsync(CancellationToken cancellationToken = default) { - return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts)); + return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts)); } /// @@ -115,6 +115,10 @@ public sealed class AgentInlineSkill : AgentSkill /// /// Registers a static resource with this skill. /// + /// + /// Resources are not automatically included in the skill body. + /// To enable discovery, reference the resource by name in the skill's instructions or in another resource. + /// /// The resource name. /// The static resource value. /// An optional description of the resource. @@ -129,6 +133,10 @@ public sealed class AgentInlineSkill : AgentSkill /// Registers a dynamic resource with this skill, backed by a C# delegate. /// The delegate's parameters and return type are automatically marshaled via AIFunctionFactory. /// + /// + /// Resources are not automatically included in the skill body. + /// To enable discovery, reference the resource by name in the skill's instructions or in another resource. + /// /// The resource name. /// A method that produces the resource value when requested. /// An optional description of the resource. @@ -147,6 +155,10 @@ public sealed class AgentInlineSkill : AgentSkill /// Registers a script with this skill, backed by a C# delegate. /// The delegate's parameters and return type are automatically marshaled via AIFunctionFactory. /// + /// + /// Only the script's parameter schema is included in the skill body (as a <script_schemas> block). + /// To enable discovery, reference the script by name in the skill's instructions or in a resource. + /// /// The script name. /// A method to execute when the script is invoked. /// An optional description of the script. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs index dabf75fa1a..d2f27edadc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs @@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI; internal static class AgentInlineSkillContentBuilder { /// - /// Builds the complete skill content containing name, description, instructions, resources, and scripts. + /// Builds the complete skill content containing name, description, instructions, and script parameter schemas. /// /// The skill name. /// The skill description. /// The raw instructions text. - /// Optional resources associated with the skill. /// Optional scripts associated with the skill. /// An XML-structured content string. public static string Build( string name, string description, string instructions, - IReadOnlyList? resources, IReadOnlyList? scripts) { _ = Throw.IfNullOrWhitespace(name); @@ -39,41 +37,24 @@ internal static class AgentInlineSkillContentBuilder .Append(EscapeXmlString(instructions)) .Append("\n"); - if (resources is { Count: > 0 }) - { - sb.Append("\n\n\n"); - foreach (var resource in resources) - { - if (resource.Description is not null) - { - sb.Append($" \n"); - } - else - { - sb.Append($" \n"); - } - } - - sb.Append(""); - } - if (scripts is { Count: > 0 }) { sb.Append('\n'); - sb.Append(BuildScriptsBlock(scripts)); + sb.Append(BuildScriptSchemasBlock(scripts)); } return sb.ToString(); } /// - /// Builds a <scripts>...</scripts> XML block for the given scripts. - /// Each script is emitted as a <script name="..."> element with optional - /// description attribute and <parameters_schema> child element. + /// Builds a <script_schemas>...</script_schemas> XML block for the given scripts. + /// Each script is emitted as a <schema script="..."> element containing only + /// the parameter schema. This block serves as a reference for the model to know how to + /// format arguments when calling scripts, not as a discovery mechanism. /// /// The scripts to include in the block. - /// An XML string starting with \n<scripts>, or an empty string if the list is empty. - public static string BuildScriptsBlock(IReadOnlyList scripts) + /// An XML string starting with \n<script_schemas>, or an empty string if the list is empty. + public static string BuildScriptSchemasBlock(IReadOnlyList scripts) { _ = Throw.IfNull(scripts); @@ -83,32 +64,23 @@ internal static class AgentInlineSkillContentBuilder } var sb = new StringBuilder(); - sb.Append("\n\n"); + sb.Append("\n\n"); foreach (var script in scripts) { var parametersSchema = script.ParametersSchema; - if (script.Description is null && parametersSchema is null) + if (parametersSchema is null) { - sb.Append($" \n"); + sb.Append($" {EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}\n"); } } - sb.Append(""); + sb.Append(""); return sb.ToString(); } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 index 07a276b9f0..1719fa9ffb 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -6,11 +6,11 @@ .DESCRIPTION The IT fixture targets stable, scenario-keyed agent names (e.g. it-happy-path) and only manages versions on each test run. The agent itself must already exist AND its managed - identity must hold the Azure AI User role on the project scope, otherwise inbound + identity must hold the Foundry User role on the project scope, otherwise inbound inference calls fail with HTTP 500 PermissionDenied. This script idempotently creates each scenario agent (with a placeholder version) and - grants Azure AI User on the project to its managed identity. Re-run it safely; existing + grants Foundry User on the project to its managed identity. Re-run it safely; existing agents and role assignments are left in place. .PARAMETER ProjectEndpoint @@ -135,20 +135,20 @@ foreach ($scenario in $Scenarios) { -Body $patchBody | Out-Null } - # 3. Grant Azure AI User on the project scope to the agent MI (idempotent). + # 3. Grant Foundry User on the project scope to the agent MI (idempotent). $existing = az role assignment list --assignee $principalId --scope $projectScope ` - --query "[?roleDefinitionName=='Azure AI User']" 2>$null | ConvertFrom-Json + --query "[?roleDefinitionName=='Foundry User']" 2>$null | ConvertFrom-Json if ($existing) { Write-Host " role already assigned" } else { - Write-Host " granting Azure AI User..." + Write-Host " granting Foundry User..." $maxAttempts = 12 $granted = $false for ($i = 1; $i -le $maxAttempts; $i++) { $output = az role assignment create ` --assignee-object-id $principalId ` --assignee-principal-type ServicePrincipal ` - --role 'Azure AI User' ` + --role 'Foundry User' ` --scope $projectScope 2>&1 if ($LASTEXITCODE -eq 0) { $granted = $true diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs index 6d24c821bc..7a8203dea7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs @@ -222,6 +222,42 @@ public class AgentResponseTests Assert.Equal(100, usageContent.Details.TotalTokenCount); } + [Fact] + public void ToAgentResponseUpdatesPropagatesCreatedAt() + { + // Sets different CreatedAt values on the AgentResponse and the ChatMessage to verify that the ChatMessage.CreatedAt is the one that gets propagated to the AgentResponseUpdate + AgentResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage", CreatedAt = new DateTimeOffset(2024, 11, 11, 9, 20, 0, TimeSpan.Zero) }) + { + AgentId = "agentId", + ResponseId = "12345", + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + Usage = new UsageDetails + { + TotalTokenCount = 100 + }, + }; + + AgentResponseUpdate[] updates = response.ToAgentResponseUpdates(); + Assert.NotNull(updates); + Assert.Equal(2, updates.Length); + + AgentResponseUpdate update0 = updates[0]; + Assert.Equal("agentId", update0.AgentId); + Assert.Equal("12345", update0.ResponseId); + Assert.Equal("someMessage", update0.MessageId); + Assert.Equal(new DateTimeOffset(2024, 11, 11, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt); + Assert.Equal("customRole", update0.Role?.Value); + Assert.Equal("Text", update0.Text); + + AgentResponseUpdate update1 = updates[1]; + Assert.Equal("value1", update1.AdditionalProperties?["key1"]); + Assert.Equal(42, update1.AdditionalProperties?["key2"]); + Assert.IsType(update1.Contents[0]); + UsageContent usageContent = (UsageContent)update1.Contents[0]; + Assert.Equal(100, usageContent.Details.TotalTokenCount); + } + [Fact] public void ParseAsStructuredOutputWithJSOSuccess() { diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs index 029a650785..bbecb7fc01 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -172,7 +172,7 @@ public class DevUIIntegrationTests Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-three" && e.Type == "workflow"); } - [Fact] + [Fact(Skip = "Flaky in merge_group; see https://github.com/microsoft/agent-framework/issues/5845")] public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync() { // Arrange diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json deleted file mode 100644 index 783215ce29..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Microsoft.Agents.AI.DevUI.UnitTests": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:63009;http://localhost:63010" - } - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index 4103517a10..f8cc4402ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -704,6 +704,35 @@ public class OutputConverterTests Assert.Equal("[{\"id\":1}]", inner); } + // K-06e: Regression — the OutputItemFunctionToolCallOutput must have a populated Id + // and a matching wire id on the added/done events. The Foundry storage layer extracts + // a partition id from this field and throws "ID cannot be null or empty (Parameter 'id')" + // when it is missing. + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionResult_OutputItemHasIdAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + var added = Assert.Single(events.OfType()); + var done = Assert.Single(events.OfType()); + + var addedOutput = Assert.IsType(added.Item); + var doneOutput = Assert.IsType(done.Item); + + Assert.False(string.IsNullOrEmpty(addedOutput.Id)); + Assert.False(string.IsNullOrEmpty(doneOutput.Id)); + Assert.Equal(addedOutput.Id, doneOutput.Id); + Assert.Equal("call_1", addedOutput.CallId); + Assert.Equal("call_1", doneOutput.CallId); + } + // L-01 [Fact] public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItemAsync() diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 713c55aaa6..7862225653 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -8,6 +8,8 @@ + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs index 4f08209bd8..2711fa1458 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Tools.Shell; #endif using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Moq; namespace Microsoft.Agents.AI.UnitTests; @@ -1460,4 +1461,131 @@ public class HarnessAgentTests #endregion #endif + + #region LoggerFactory and ServiceProvider + + /// + /// Verify that the constructor succeeds when loggerFactory is provided. + /// + [Fact] + public void Constructor_SucceedsWithLoggerFactory() + { + // Arrange + var chatClient = new Mock().Object; + var loggerFactory = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that the constructor succeeds when serviceProvider is provided. + /// + [Fact] + public void Constructor_SucceedsWithServiceProvider() + { + // Arrange + var chatClient = new Mock().Object; + var services = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: services); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that the constructor succeeds when both loggerFactory and serviceProvider are provided. + /// + [Fact] + public void Constructor_SucceedsWithLoggerFactoryAndServiceProvider() + { + // Arrange + var chatClient = new Mock().Object; + var loggerFactory = new Mock().Object; + var services = new Mock().Object; + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that AsHarnessAgent extension method accepts loggerFactory and serviceProvider. + /// + [Fact] + public void AsHarnessAgent_SucceedsWithLoggerFactoryAndServiceProvider() + { + // Arrange + var chatClient = new Mock().Object; + var loggerFactory = new Mock().Object; + var services = new Mock().Object; + + // Act + var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that ILoggerFactory is threaded to downstream components by confirming CreateLogger is called. + /// + [Fact] + public void Constructor_LoggerFactoryIsUsedByDownstreamComponents() + { + // Arrange + var chatClient = new Mock().Object; + var mockLoggerFactory = new Mock(); + mockLoggerFactory + .Setup(lf => lf.CreateLogger(It.IsAny())) + .Returns(new Mock().Object); + + // Act — use options that leave CompactionProvider and AgentSkillsProvider enabled + var options = new HarnessAgentOptions + { + DisableToolApproval = true, + DisableOpenTelemetry = true, + DisableFileMemory = true, + DisableFileAccess = true, + DisableWebSearch = true, + DisableTodoProvider = true, + DisableAgentModeProvider = true, + }; + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options, mockLoggerFactory.Object); + + // Assert — CreateLogger should have been called by one or more downstream components + Assert.NotNull(agent); + mockLoggerFactory.Verify(lf => lf.CreateLogger(It.IsAny()), Times.AtLeastOnce()); + } + + /// + /// Verify that IServiceProvider is propagated through the agent pipeline by confirming + /// it is queried during agent construction. + /// + [Fact] + public void Constructor_ServiceProviderIsQueriedDuringBuild() + { + // Arrange + var chatClient = new Mock().Object; + var mockServices = new Mock(); + mockServices + .Setup(sp => sp.GetService(It.IsAny())) + .Returns(null!); + + // Act + var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: mockServices.Object); + + // Assert — the service provider should have been queried during pipeline construction + Assert.NotNull(agent); + mockServices.Verify(sp => sp.GetService(It.IsAny()), Times.AtLeastOnce()); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json deleted file mode 100644 index 6b8f8d04a4..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Microsoft.Agents.AI.Hosting.A2A.UnitTests": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:52186;http://localhost:52187" - } - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs index 785a3b2e00..33b842bf34 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs @@ -102,7 +102,7 @@ public sealed class SessionPersistenceTests : IAsyncDisposable // Register agent using hosting DI pattern with InMemorySessionStore builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name)) - .WithInMemorySessionStore(); + .WithInMemorySessionStore(withIsolation: false); this._app = builder.Build(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Properties/launchSettings.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Properties/launchSettings.json deleted file mode 100644 index 099bd7018e..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Microsoft.Agents.AI.Hosting.OpenAI.UnitTests": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:60491;http://localhost:60492" - } - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs new file mode 100644 index 0000000000..e22feec1a9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public class ClaimsIdentitySessionIsolationKeyProviderTests +{ + private const string TestUserId = "test-user-id"; + private const string CustomClaimType = "custom-claim-type"; + private const string CustomClaimValue = "custom-claim-value"; + + private readonly Mock _httpContextAccessorMock; + + /// + /// Initializes a new instance of the class. + /// + public ClaimsIdentitySessionIsolationKeyProviderTests() + { + this._httpContextAccessorMock = new Mock(); + } + + #region Constructor Tests + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Act & Assert - should not throw + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null); + Assert.NotNull(provider); + } + + /// + /// Verify that constructor accepts null IHttpContextAccessor. + /// + [Fact] + public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() + { + // Act & Assert - should not throw + var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null); + Assert.NotNull(provider); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is null. + /// + [Fact] + public void RequiresClaimType_NotNull() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! })); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is empty. + /// + [Fact] + public void RequiresClaimType_NotEmpty() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty })); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is whitespace. + /// + [Fact] + public void RequiresClaimType_NotWhitespace() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " })); + } + + #endregion + + #region GetSessionIsolationKeyAsync Tests + + /// + /// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(TestUserId, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); + var provider = new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType }); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(CustomClaimValue, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify behavior when HttpContextAccessor returns null HttpContext. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync() + { + // Arrange + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify behavior when HttpContextAccessor itself is null. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync() + { + // Arrange + var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync() + { + // Arrange + const string FirstValue = "first-value"; + const string SecondValue = "second-value"; + var claims = new[] + { + new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue), + new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext + { + User = principal + }; + + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(FirstValue, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync handles empty claim values. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(string.Empty, result); + } + + #endregion + + #region Helper Methods + + private void SetupHttpContextWithClaim(string claimType, string claimValue) + { + var claims = new[] { new Claim(claimType, claimValue) }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext + { + User = principal + }; + + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs new file mode 100644 index 0000000000..f04ad7c20d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DelegatingAgentSessionStoreTests +{ + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly TestDelegatingAgentSessionStore _delegatingStore; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public DelegatingAgentSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = new Mock(); + this._testSession = new TestAgentSession(); + + // Setup inner store mock + this._innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(this._testSession); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerStore is null. + /// + [Fact] + public void RequiresInnerStore() => + // Act & Assert + Assert.Throws("innerStore", () => new TestDelegatingAgentSessionStore(null!)); + + /// + /// Verify that constructor sets the inner store correctly. + /// + [Fact] + public void Constructor_WithValidInnerStore_SetsInnerStore() + { + // Act + var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + + // Assert + Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore); + } + + #endregion + + #region Method Delegation Tests + + /// + /// Verify that GetSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task GetSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + const string ExpectedConversationId = "test-conversation-id"; + var expectedCancellationToken = new CancellationToken(); + + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + It.Is(a => a == this._agentMock.Object), + It.Is(c => c == ExpectedConversationId), + It.Is(ct => ct == expectedCancellationToken))) + .ReturnsAsync(this._testSession); + + // Act + var session = await this._delegatingStore.GetSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + expectedCancellationToken); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + const string ExpectedConversationId = "test-conversation-id"; + var expectedCancellationToken = new CancellationToken(); + var expectedSession = new TestAgentSession(); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.Is(a => a == this._agentMock.Object), + It.Is(c => c == ExpectedConversationId), + It.Is(s => s == expectedSession), + It.Is(ct => ct == expectedCancellationToken))) + .Returns(ValueTask.CompletedTask); + + // Act + await this._delegatingStore.SaveSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + expectedSession, + expectedCancellationToken); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + ExpectedConversationId, + expectedSession, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that GetSessionAsync awaits the inner store's result before returning. + /// + [Fact] + public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() + { + // Arrange + const string ExpectedConversationId = "test-conversation-id"; + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(this._testSession); + Assert.True(resultTask.IsCompleted); + Assert.Same(this._testSession, await resultTask); + } + + /// + /// Verify that SaveSessionAsync awaits the inner store's completion before returning. + /// + [Fact] + public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() + { + // Arrange + const string ExpectedConversationId = "test-conversation-id"; + var expectedSession = new TestAgentSession(); + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(); + Assert.True(resultTask.IsCompleted); + await resultTask; + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService returns itself when requesting the exact type. + /// + [Fact] + public void GetServiceReturnsItselfForExactType() + { + // Act + var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore)); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService returns itself when requesting a base type. + /// + [Fact] + public void GetServiceReturnsItselfForBaseType() + { + // Act + var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore)); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService returns itself when requesting AgentSessionStore. + /// + [Fact] + public void GetServiceReturnsItselfForAgentSessionStoreType() + { + // Act + var result = this._delegatingStore.GetService(typeof(AgentSessionStore)); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService chains to inner store when type is not satisfied by outer store. + /// + [Fact] + public void GetServiceChainsToInnerStore() + { + // Arrange + var innerStore = new ConcreteAgentSessionStore(); + var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); + + // Act + var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore)); + + // Assert + Assert.Same(innerStore, result); + } + + /// + /// Verify that GetService chains through multiple delegation layers. + /// + [Fact] + public void GetServiceChainsThoughMultipleDelegationLayers() + { + // Arrange - create a three-layer chain: outer -> middle -> inner + var innerStore = new ConcreteAgentSessionStore(); + var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); + var outerStore = new TestDelegatingAgentSessionStore(middleStore); + + // Act - request the innermost store type + var result = outerStore.GetService(typeof(ConcreteAgentSessionStore)); + + // Assert + Assert.Same(innerStore, result); + } + + /// + /// Verify that GetService can find a store in the middle of the delegation chain. + /// + [Fact] + public void GetServiceFindsMiddleStoreInChain() + { + // Arrange - create a three-layer chain: outer -> middle -> inner + var innerStore = new ConcreteAgentSessionStore(); + var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); + var outerStore = new TestDelegatingAgentSessionStore(middleStore); + + // Act - request the middle store type + var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore)); + + // Assert + Assert.Same(middleStore, result); + } + + /// + /// Verify that GetService returns null when the requested type is not found in the chain. + /// + [Fact] + public void GetServiceReturnsNullWhenTypeNotFound() + { + // Arrange + var innerStore = new ConcreteAgentSessionStore(); + var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); + + // Act + var result = delegatingStore.GetService(typeof(string)); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService returns null when a service key is provided but not matched. + /// + [Fact] + public void GetServiceReturnsNullWhenServiceKeyProvided() + { + // Act + var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key"); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService throws ArgumentNullException when serviceType is null. + /// + [Fact] + public void GetServiceThrowsWhenServiceTypeIsNull() => + Assert.Throws("serviceType", () => this._delegatingStore.GetService(null!)); + + /// + /// Verify that GetService generic method works correctly. + /// + [Fact] + public void GetServiceGenericReturnsItself() + { + // Act + var result = this._delegatingStore.GetService(); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService generic method chains to inner store. + /// + [Fact] + public void GetServiceGenericChainsToInnerStore() + { + // Arrange + var innerStore = new ConcreteAgentSessionStore(); + var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); + + // Act + var result = delegatingStore.GetService(); + + // Assert + Assert.Same(innerStore, result); + } + + /// + /// Verify that GetService generic method returns null when type not found. + /// + [Fact] + public void GetServiceGenericReturnsNullWhenTypeNotFound() + { + // Act + var result = this._delegatingStore.GetService(); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Test Implementation + + /// + /// Test implementation of DelegatingAgentSessionStore for testing purposes. + /// + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore) + { + public new AgentSessionStore InnerStore => base.InnerStore; + } + + /// + /// Another delegating store implementation for testing multi-layer chains. + /// + private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); + + /// + /// Concrete (non-delegating) session store for testing GetService chaining. + /// + private sealed class ConcreteAgentSessionStore : AgentSessionStore + { + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => new(new TestAgentSession()); + + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } + + private sealed class TestAgentSession : AgentSession; + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs new file mode 100644 index 0000000000..d410543608 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -0,0 +1,430 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public class IsolationKeyScopedAgentSessionStoreTests +{ + private const string TestIsolationKey = "test-key"; + private const string TestConversationId = "test-conversation-id"; + + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public IsolationKeyScopedAgentSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = new Mock(); + this._testSession = new TestAgentSession(); + + this._innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(this._testSession); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerStore is null. + /// + [Fact] + public void RequiresInnerStore() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + + // Act & Assert + Assert.Throws("innerStore", () => + new IsolationKeyScopedAgentSessionStore(null!, provider)); + } + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + + // Act & Assert - should not throw + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null); + Assert.NotNull(store); + } + + #endregion + + #region GetSessionAsync Tests + + /// + /// Verify that GetSessionAsync scopes the conversation ID with the isolation key. + /// + [Fact] + public async Task GetSessionAsyncScopesConversationIdWithKeyAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"{TestIsolationKey}::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode. + /// + [Fact] + public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); + + Assert.Contains("Session isolation key is required", exception.Message); + } + + /// + /// Verify that GetSessionAsync does not throw when key is null in non-strict mode. + /// + [Fact] + public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); + + // Act - should not throw + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - conversation ID should be passed through unmodified + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + TestConversationId, + It.IsAny()), + Times.Once); + } + + /// + /// Verify that GetSessionAsync returns the session from the inner store. + /// + [Fact] + public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + Assert.Same(this._testSession, result); + } + + #endregion + + #region SaveSessionAsync Tests + + /// + /// Verify that SaveSessionAsync scopes the conversation ID with the isolation key. + /// + [Fact] + public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + var sessionToSave = new TestAgentSession(); + + // Act + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + $"{TestIsolationKey}::{TestConversationId}", + sessionToSave, + It.IsAny()), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode. + /// + [Fact] + public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + var sessionToSave = new TestAgentSession(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); + + Assert.Contains("Session isolation key is required", exception.Message); + } + + /// + /// Verify that SaveSessionAsync does not throw when key is null in non-strict mode. + /// + [Fact] + public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); + var sessionToSave = new TestAgentSession(); + + // Act - should not throw + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert - conversation ID should be passed through unmodified + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + TestConversationId, + sessionToSave, + It.IsAny()), + Times.Once); + } + + #endregion + + #region Escaping Tests + + /// + /// Verify that colons in the isolation key are escaped. + /// + [Fact] + public async Task EscapesColonsInIsolationKeyAsync() + { + // Arrange + const string KeyWithColon = "key:with:colons"; + var provider = new TestSessionIsolationKeyProvider(KeyWithColon); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - colons should be escaped as \: + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"key\\:with\\:colons::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that backslashes in the isolation key are escaped. + /// + [Fact] + public async Task EscapesBackslashesInIsolationKeyAsync() + { + // Arrange + const string KeyWithBackslash = @"domain\key"; + var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - backslashes should be escaped as \\ + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"domain\\\\key::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that both backslashes and colons in the isolation key are escaped correctly. + /// + [Fact] + public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync() + { + // Arrange + const string KeyWithBoth = @"domain\key:role"; + var provider = new TestSessionIsolationKeyProvider(KeyWithBoth); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - backslashes escaped first, then colons + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"domain\\\\key\\:role::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + #endregion + + #region Isolation Tests + + /// + /// Verify that different isolation keys result in different scoped conversation IDs. + /// + [Fact] + public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync() + { + // Arrange + const string Key1 = "key-1"; + const string Key2 = "key-2"; + string? capturedConversationId1 = null; + string? capturedConversationId2 = null; + + this._innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, conversationId, _) => + { + if (capturedConversationId1 == null) + { + capturedConversationId1 = conversationId; + } + else + { + capturedConversationId2 = conversationId; + } + }) + .ReturnsAsync(this._testSession); + + // Act - Key 1 + var provider1 = new TestSessionIsolationKeyProvider(Key1); + var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1); + await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Act - Key 2 + var provider2 = new TestSessionIsolationKeyProvider(Key2); + var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2); + await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1); + Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2); + Assert.NotEqual(capturedConversationId1, capturedConversationId2); + } + + #endregion + + #region GetService Tests + + /// + /// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain. + /// + [Fact] + public void GetServiceReturnsIsolationKeyScopedAgentSessionStore() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + var result = store.GetService(); + + // Assert + Assert.Same(store, result); + } + + /// + /// Verify that GetService chains through to find inner store types. + /// + [Fact] + public void GetServiceChainsToInnerStore() + { + // Arrange + var concreteInnerStore = new ConcreteAgentSessionStore(); + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider); + + // Act + var result = store.GetService(); + + // Assert + Assert.Same(concreteInnerStore, result); + } + + #endregion + + #region Helper Classes + + /// + /// Test implementation of for testing purposes. + /// + private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + private readonly string? _key; + + public TestSessionIsolationKeyProvider(string? key) + { + this._key = key; + } + + public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(this._key); + } + } + + private sealed class TestAgentSession : AgentSession; + + /// + /// Concrete (non-delegating) session store for testing GetService chaining. + /// + private sealed class ConcreteAgentSessionStore : AgentSessionStore + { + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => new(new TestAgentSession()); + + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj index 1279b20397..a6e2ccdb38 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj @@ -6,6 +6,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs new file mode 100644 index 0000000000..00bf2cd373 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for and its contract. +/// +public class SessionIsolationKeyProviderTests +{ + /// + /// Verify that a concrete provider can return a non-null isolation key. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync() + { + // Arrange + const string ExpectedKey = "test-key"; + var provider = new TestSessionIsolationKeyProvider(ExpectedKey); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(ExpectedKey, result); + } + + /// + /// Verify that a concrete provider can return null when no key is available. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that cancellation token is passed through to the provider implementation. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync() + { + // Arrange + var provider = new TestCancellableSessionIsolationKeyProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await provider.GetSessionIsolationKeyAsync(cts.Token)); + } + + #region Test Implementations + + /// + /// Test implementation of for testing purposes. + /// + private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + private readonly string? _key; + + public TestSessionIsolationKeyProvider(string? key) + { + this._key = key; + } + + public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(this._key); + } + } + + /// + /// Test implementation that respects cancellation tokens. + /// + private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + public override async ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + await Task.Delay(1000, cancellationToken); + return "key"; + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 1248866a52..17bf71a388 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -51,9 +51,8 @@ public sealed class AgentClassSkillTests // Act & Assert — Content is cached Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync()); - // Act & Assert — Content includes parameter schema from typed script - Assert.Contains("parameters_schema", await skill.GetContentAsync()); - Assert.Contains("value", await skill.GetContentAsync()); + // Act & Assert — Content includes parameter schema from typed script (with preserved quotes) + Assert.Contains("\"value\"", await skill.GetContentAsync()); } [Fact] @@ -383,10 +382,9 @@ public sealed class AgentClassSkillTests // Arrange var skill = new AttributedFullSkill(); - // Act & Assert — Content includes reflected resources and scripts - Assert.Contains("", await skill.GetContentAsync()); - Assert.Contains("conversion-table", await skill.GetContentAsync()); - Assert.Contains("", await skill.GetContentAsync()); + // Act & Assert — Content no longer includes resources in body; scripts are in script_schemas + Assert.DoesNotContain("", await skill.GetContentAsync()); + Assert.Contains("", await skill.GetContentAsync()); Assert.Contains("convert", await skill.GetContentAsync()); // Act & Assert — discovered members are cached @@ -504,7 +502,7 @@ public sealed class AgentClassSkillTests } [Fact] - public async Task Content_IncludesDescription_ForReflectedResourcesAsync() + public async Task Content_DoesNotRenderResources_InBodyAsync() { // Arrange var skill = new AttributedResourcePropertiesSkill(); @@ -512,8 +510,8 @@ public sealed class AgentClassSkillTests // Act var content = await skill.GetContentAsync(); - // Assert — descriptions from [Description] attribute appear in synthesized content - Assert.Contains("Some important data.", content); + // Assert — resources are no longer rendered in body content + Assert.DoesNotContain("", content); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs index 9a27528051..aa001fd2a0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -122,11 +122,10 @@ public sealed class AgentFileSkillScriptTests // Assert — content starts with original and appends per-script entries Assert.StartsWith("Original content", content); - Assert.Contains("", content); - Assert.Contains("