mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5cbbc17b | ||
|
|
f38c17a406 | ||
|
|
b298113d15 | ||
|
|
8091d052d8 | ||
|
|
52a8045bb6 | ||
|
|
78d175a1e2 | ||
|
|
b59a854fcd | ||
|
|
8b0db48d33 | ||
|
|
5affc9c333 | ||
|
|
edcc786651 | ||
|
|
07a1e83492 | ||
|
|
fa2a6af443 | ||
|
|
11c8d89ab2 | ||
|
|
6510d6e3c8 | ||
|
|
dd9a4b6321 | ||
|
|
e8ff541ebf | ||
|
|
d2d5384f28 | ||
|
|
1fccf16f11 | ||
|
|
8ed2159c4b | ||
|
|
b000a2cf51 | ||
|
|
0578f4c910 | ||
|
|
e9a606344a | ||
|
|
d2f79930d5 | ||
|
|
b1e9efee7e | ||
|
|
3ee1bb4f9f | ||
|
|
945647a065 | ||
|
|
401a552735 | ||
|
|
718a1f14fd | ||
|
|
f7c5b8d108 | ||
|
|
e6762ea876 | ||
|
|
08abe9e704 | ||
|
|
a84ad42f6d | ||
|
|
ded17b178c | ||
|
|
55dc3ce734 | ||
|
|
9d8e5ca4f5 | ||
|
|
af787569b3 | ||
|
|
3db2004e49 | ||
|
|
efdabd56dc | ||
|
|
371a869e44 | ||
|
|
e532ced950 |
@@ -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
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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,
|
||||
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 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 query = `repo:${owner}/${repo} is:pr is:open author:${author}`;
|
||||
const response = await github.rest.search.issuesAndPullRequests({
|
||||
q: query,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const indexedPrNumbers = response.data.items.map((item) => item.number);
|
||||
const currentPrIsIndexed = indexedPrNumbers.includes(pullRequestNumber);
|
||||
if (currentPrIsIndexed || response.data.total_count >= 100) {
|
||||
return response.data.total_count;
|
||||
}
|
||||
|
||||
return response.data.total_count + 1;
|
||||
}
|
||||
|
||||
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const { author, labels, number } = getPullRequest(context);
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
// 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', labels = [], number = 123 } = {}) {
|
||||
return {
|
||||
repo: {
|
||||
owner: 'microsoft',
|
||||
repo: 'agent-framework',
|
||||
},
|
||||
payload: {
|
||||
pull_request: {
|
||||
number,
|
||||
labels: labels.map((name) => ({ name })),
|
||||
user: {
|
||||
login: author,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCore() {
|
||||
const messages = [];
|
||||
return {
|
||||
messages,
|
||||
info(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createGithub({ totalCount, itemNumbers, labelExists = true }) {
|
||||
const calls = [];
|
||||
|
||||
return {
|
||||
calls,
|
||||
rest: {
|
||||
search: {
|
||||
async issuesAndPullRequests(params) {
|
||||
calls.push({ api: 'search.issuesAndPullRequests', params });
|
||||
return {
|
||||
data: {
|
||||
total_count: totalCount,
|
||||
items: itemNumbers.map((number) => ({ number })),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
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 update(params) {
|
||||
calls.push({ api: 'pulls.update', params });
|
||||
return { data: { state: params.state } };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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({
|
||||
totalCount: 10,
|
||||
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),
|
||||
['search.issuesAndPullRequests'],
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the new PR when search has not indexed it yet', async () => {
|
||||
const github = createGithub({
|
||||
totalCount: 10,
|
||||
itemNumbers: [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),
|
||||
[
|
||||
'search.issuesAndPullRequests',
|
||||
'issues.getLabel',
|
||||
'issues.addLabels',
|
||||
'issues.createComment',
|
||||
'pulls.update',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the label when it does not already exist', async () => {
|
||||
const github = createGithub({
|
||||
totalCount: 11,
|
||||
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),
|
||||
[
|
||||
'search.issuesAndPullRequests',
|
||||
'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({
|
||||
totalCount: 11,
|
||||
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),
|
||||
[
|
||||
'search.issuesAndPullRequests',
|
||||
'issues.getLabel',
|
||||
'issues.createLabel',
|
||||
'issues.addLabels',
|
||||
'issues.createComment',
|
||||
'pulls.update',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a diplomatic close message with the configured limit', async () => {
|
||||
const github = createGithub({
|
||||
totalCount: 11,
|
||||
itemNumbers: [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({
|
||||
totalCount: 11,
|
||||
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 over-count when the current PR is not on the first search page', async () => {
|
||||
const github = createGithub({
|
||||
totalCount: 101,
|
||||
itemNumbers: Array.from({ length: 100 }, (_, index) => index + 1),
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ number: 123 }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, true);
|
||||
assert.equal(result.openPrCount, 101);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,9 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
@@ -191,6 +194,9 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||
@@ -365,6 +371,9 @@ jobs:
|
||||
dotnet
|
||||
python
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
@@ -452,6 +461,9 @@ jobs:
|
||||
python
|
||||
declarative-agents
|
||||
|
||||
- name: Free runner disk space
|
||||
uses: ./.github/actions/free-runner-disk-space
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Limit community pull requests
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-limit-${{ github.repository }}-${{ github.event.pull_request.user.login }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
MAX_OPEN_PULL_REQUESTS: '10'
|
||||
PR_LIMIT_EXEMPT_LABEL: pr-limit-exempt
|
||||
TOO_MANY_PRS_LABEL: too-many-prs
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.PR_NUMBER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; skipping open PR limit.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a team member; checking open PR limit.`);
|
||||
}
|
||||
|
||||
limit_open_prs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce open PR limit
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
|
||||
await enforcePrLimit({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
exemptLabelName: process.env.PR_LIMIT_EXEMPT_LABEL,
|
||||
maxOpenPrs: Number.parseInt(process.env.MAX_OPEN_PULL_REQUESTS, 10),
|
||||
labelName: process.env.TOO_MANY_PRS_LABEL,
|
||||
});
|
||||
@@ -23,6 +23,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Chrome for Puppeteer
|
||||
run: npx puppeteer browsers install chrome
|
||||
|
||||
# Checks the status of hyperlinks in all files
|
||||
- name: Run linkspector
|
||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.25" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.5" />
|
||||
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.55.0" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.56.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.11.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.12.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
@@ -604,6 +605,7 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mcp/Microsoft.Agents.AI.Mcp.csproj" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.7.0</VersionPrefix>
|
||||
<VersionPrefix>1.8.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260526</DateSuffix>
|
||||
<DateSuffix>260528</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.7.0</GitTag>
|
||||
<GitTag>1.8.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox MCP Skills.
|
||||
//
|
||||
// Uses AgentSkillsProviderBuilder to discover MCP-based skills from a Foundry
|
||||
// Toolbox endpoint and inject them as AIContextProviders so the agent can
|
||||
// discover and use them at runtime.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
// --- Configuration ---
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
string toolboxMcpServerUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL")
|
||||
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_MCP_SERVER_URL is not set.");
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
|
||||
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
// --- Connect to the Foundry Toolbox MCP endpoint ---
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = "foundry_toolbox",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient));
|
||||
|
||||
// --- Discover MCP-based skills ---
|
||||
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
// --- Create the agent ---
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(
|
||||
options: new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ToolboxMcpSkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
|
||||
// --- Interactive prompt ---
|
||||
Console.Write("User: ");
|
||||
string? query = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Console.WriteLine("No input provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Assistant: {await agent.RunAsync(query)}");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DelegatingHandler: attaches a fresh Foundry bearer token to every request
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Foundry Toolbox MCP Skills
|
||||
|
||||
This sample uses
|
||||
`AgentSkillsProviderBuilder` to discover MCP-based skills from a Foundry Toolbox endpoint
|
||||
and inject them as `AIContextProviders` so the agent can discover and use them at runtime.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
|
||||
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
|
||||
- Using `AgentSkillsProviderBuilder.UseMcpSkills(client)` to discover skills from the toolbox
|
||||
- Injecting the discovered skills into `AIProjectClient.AsAIAgent(...)` via `AIContextProviders`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a toolbox already configured
|
||||
- The toolbox MCP endpoint must expose `skill://index.json` with `skill-md` entries (SEP-2640). If the resource is absent, the sample runs but the skills provider will be empty.
|
||||
- Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
$env:FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-foundry-service.services.ai.azure.com/api/projects/your-project/toolboxes/your-toolbox/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -74,6 +74,7 @@ Some samples require extra tool-specific environment variables. See each sample
|
||||
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
|
||||
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
|
||||
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
|
||||
| [Foundry toolbox MCP skills](./Agent_Step26_FoundryToolboxMcpSkills/) | Use a Foundry Toolbox with MCP-based skills discovery (SEP-2640) via AIContextProviders |
|
||||
|
||||
## Running the samples
|
||||
|
||||
|
||||
@@ -72,6 +72,95 @@ public static class AnsiEscapes
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the visible (printed) length of a string after stripping ANSI escape sequences.
|
||||
/// Escape sequences are zero-width on screen but occupy characters in the raw string.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji,
|
||||
/// combining characters, variation selectors, and East Asian wide characters may be
|
||||
/// measured incorrectly. For the console harness this is acceptable since content is
|
||||
/// predominantly ASCII, and emoji are padded with surrounding spaces.
|
||||
/// </remarks>
|
||||
public static int VisibleLength(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int length = 0;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[')
|
||||
{
|
||||
// Skip the ESC[ and all characters up to and including the final byte (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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of physical terminal rows a text item will occupy,
|
||||
/// accounting for both explicit newlines and terminal line wrapping.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).</param>
|
||||
/// <returns>The number of physical rows the text occupies.</returns>
|
||||
public static int CountPhysicalLines(string text, int terminalWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int physicalLines = 0;
|
||||
int lineStart = 0;
|
||||
|
||||
for (int i = 0; i <= text.Length; i++)
|
||||
{
|
||||
if (i == text.Length || text[i] == '\n')
|
||||
{
|
||||
if (terminalWidth <= 0)
|
||||
{
|
||||
// No wrapping — each logical line is one physical row
|
||||
physicalLines += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicalLine = text[lineStart..i];
|
||||
int visibleWidth = VisibleLength(logicalLine);
|
||||
|
||||
physicalLines += visibleWidth == 0
|
||||
? 1
|
||||
: (visibleWidth - 1) / terminalWidth + 1;
|
||||
}
|
||||
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
physicalLines--;
|
||||
}
|
||||
|
||||
return physicalLines;
|
||||
}
|
||||
|
||||
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||
{
|
||||
ConsoleColor.Black => 30,
|
||||
|
||||
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items.
|
||||
/// Calculates the height (in lines) needed to render all items,
|
||||
/// accounting for terminal line wrapping at the specified width.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <returns>The total number of lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
||||
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||
/// <returns>The total number of physical lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
total += CountLines(items[i]);
|
||||
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
{
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
|
||||
int itemRow = 0;
|
||||
|
||||
for (int j = 0; j < lineCount; j++)
|
||||
for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++)
|
||||
{
|
||||
int linePhysicalRows = props.Width > 0
|
||||
? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1)
|
||||
: 1;
|
||||
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
currentRow++;
|
||||
|
||||
currentRow += linePhysicalRows;
|
||||
itemRow += linePhysicalRows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
count--;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Calculate the offset from bottom for the start of the new last item
|
||||
int lastItemLines = CountLines(props.Items[^1]);
|
||||
// Calculate the offset from bottom for the start of the new last item,
|
||||
// accounting for terminal line wrapping at the available width.
|
||||
int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width);
|
||||
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||
|
||||
// Update rendered count
|
||||
this._renderedCount = props.Items.Count;
|
||||
}
|
||||
|
||||
private static int CountLines(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 1;
|
||||
for (int i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (text[i] == '\n')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// If text ends with a newline, don't count the trailing empty line
|
||||
if (text[text.Length - 1] == '\n')
|
||||
{
|
||||
count--;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -13,6 +13,11 @@ public abstract class ConsoleReactiveComponent
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
|
||||
/// </summary>
|
||||
protected static object RenderLock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
||||
/// Used by parent components to set layout (X, Y, Width, Height) on children without
|
||||
@@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
where TProps : ConsoleReactiveProps
|
||||
where TState : ConsoleReactiveState
|
||||
{
|
||||
private readonly object _renderLock = new();
|
||||
private TProps? _lastRenderedProps;
|
||||
private TState? _lastRenderedState;
|
||||
|
||||
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// </summary>
|
||||
public override void Render()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
lock (RenderLock)
|
||||
{
|
||||
if (this.Props is null)
|
||||
{
|
||||
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
||||
/// <inheritdoc/>
|
||||
public override void Invalidate()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
lock (RenderLock)
|
||||
{
|
||||
this._lastRenderedProps = default;
|
||||
this._lastRenderedState = default;
|
||||
|
||||
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
private int _scrollRegionBottom;
|
||||
private bool _resizedSinceLastRender = true;
|
||||
private bool _deactivated;
|
||||
private BottomPanelMode _lastRenderedBottomPanelMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
|
||||
// When the bottom panel mode changes, the new child must repaint even if its
|
||||
// props haven't changed — the screen area was overwritten by the previous child.
|
||||
if (state.Mode != this._lastRenderedBottomPanelMode)
|
||||
{
|
||||
bottomChild.Invalidate();
|
||||
this._lastRenderedBottomPanelMode = state.Mode;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
|
||||
+7
-5
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
{
|
||||
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
catch (JsonException)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
||||
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
||||
// JSON parsing failed — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planningResponse is null)
|
||||
{
|
||||
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||
// Null result — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||
}
|
||||
|
||||
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
// Unexpected type — fall back to rendering as regular text output.
|
||||
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -7,20 +7,20 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
|
||||
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatCompleteTodos(call),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"todos_add" => FormatAddTodos(call),
|
||||
"todos_complete" => FormatCompleteTodos(call),
|
||||
"todos_remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
+88
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
|
||||
using System.Text.Json;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
||||
|
||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string detail = GetContentFilterDetails(incompleteUpdate);
|
||||
const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.";
|
||||
await ux.WriteInfoLineAsync(
|
||||
string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}",
|
||||
ConsoleColor.Yellow);
|
||||
}
|
||||
else
|
||||
{
|
||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts content filter details from the serialized response JSON and returns
|
||||
/// a formatted string showing which specific categories were triggered.
|
||||
/// Returns <see cref="string.Empty"/> if details cannot be extracted.
|
||||
/// </summary>
|
||||
private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate);
|
||||
using var doc = JsonDocument.Parse(data.ToString());
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Navigate into the nested response object if present.
|
||||
JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root;
|
||||
|
||||
if (!responseElement.TryGetProperty("content_filters", out var filtersArray)
|
||||
|| filtersArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
foreach (var filter in filtersArray.EnumerateArray())
|
||||
{
|
||||
if (!filter.TryGetProperty("content_filter_results", out var results)
|
||||
|| results.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect category data for aligned output.
|
||||
var categories = new List<(string Name, bool Filtered, string? Severity)>();
|
||||
foreach (var category in results.EnumerateObject())
|
||||
{
|
||||
if (category.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean();
|
||||
string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null;
|
||||
categories.Add((category.Name, filtered, severity));
|
||||
}
|
||||
|
||||
// Build all category lines into a single string.
|
||||
int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0;
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var (name, filtered, severity) in categories)
|
||||
{
|
||||
string paddedName = name.PadRight(maxNameLen);
|
||||
string icon = filtered ? "❌" : "✅";
|
||||
string statusText = filtered ? "Filtered " : "Not Filtered";
|
||||
string severityText = severity is not null ? $" Severity: {severity}" : "";
|
||||
|
||||
lines.Add($" {icon} {paddedName} {statusText}{severityText}");
|
||||
}
|
||||
|
||||
if (lines.Count > 0)
|
||||
{
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Parsing not critical — skip silently if it fails.
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -13,8 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+2
@@ -13,8 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -101,6 +101,10 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -49,6 +49,10 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// 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 });
|
||||
|
||||
// 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
|
||||
|
||||
@@ -28,6 +28,10 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -148,6 +152,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
|
||||
@@ -297,7 +297,7 @@ public class AgentResponse
|
||||
AgentId = this.AgentId,
|
||||
ResponseId = this.ResponseId,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = this.CreatedAt,
|
||||
CreatedAt = message.CreatedAt ?? this.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -281,14 +281,19 @@ internal static class OutputConverter
|
||||
|
||||
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
|
||||
|
||||
var itemId = GenerateItemId("fc");
|
||||
var outputItem = new OutputItemFunctionToolCallOutput(
|
||||
// Use the SDK's convenience method so the OutputItemFunctionToolCallOutput
|
||||
// is constructed with a populated Id. The public OutputItemFunctionToolCallOutput
|
||||
// ctor only sets CallId/Output (Id is read-only), and AddOutputItem<T>+EmitAdded
|
||||
// does not auto-stamp Id — only ResponseId/AgentReference. Without this, the
|
||||
// serialized item arrives at the Foundry storage layer with id=null and is
|
||||
// rejected with "ID cannot be null or empty (Parameter 'id')".
|
||||
foreach (var evt in stream.OutputItemFunctionCallOutput(
|
||||
functionResult.CallId,
|
||||
BinaryData.FromString(outputText));
|
||||
BinaryData.FromString(outputText)))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
|
||||
yield return outputBuilder.EmitAdded(outputItem);
|
||||
yield return outputBuilder.EmitDone(outputItem);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The A2A <c>contextId</c> arrives from the wire
|
||||
/// and is treated as a chain-resume identifier — <em>not</em> as an authorization
|
||||
/// token. The <see cref="AgentSessionStore"/> contract carries no principal/owner
|
||||
/// dimension, so when a persistent store is registered any caller who knows or
|
||||
/// guesses another caller's <c>contextId</c> 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
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>). When no isolation provider is
|
||||
/// registered, behavior is unchanged — the bare <c>contextId</c> is used as the
|
||||
/// conversation identifier, which is appropriate for first-run / single-user /
|
||||
/// prototyping scenarios but unsafe for multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
@@ -46,6 +63,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -65,6 +89,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
@@ -83,6 +114,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -114,6 +152,13 @@ public static class A2AServerServiceCollectionExtensions
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
|
||||
/// for guidance on multi-user hosts (the wire <c>contextId</c> is a chain-resume
|
||||
/// identifier, not an authorization token; multi-user hosts must compose a
|
||||
/// principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -140,9 +185,17 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() 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);
|
||||
}
|
||||
|
||||
+20
@@ -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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The AG-UI <c>RunAgentInput.ThreadId</c> arrives
|
||||
/// from the wire and is treated as a chain-resume identifier — <em>not</em> as an
|
||||
/// authorization token. The <see cref="AgentSessionStore"/> contract carries no
|
||||
/// principal/owner dimension, so when a persistent store is registered any caller
|
||||
/// who knows or guesses another caller's <c>ThreadId</c> 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 <see cref="AgentSessionStore"/> in
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
|
||||
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
|
||||
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
|
||||
/// isolation provider is registered, behavior is unchanged — the bare
|
||||
/// <c>ThreadId</c> is used as the conversation identifier, which is appropriate
|
||||
/// for first-run / single-user / prototyping scenarios but unsafe for
|
||||
/// multi-user hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapAGUI(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
|
||||
+78
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
|
||||
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
|
||||
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// will then enforce strict or pass-through behavior based on its configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
|
||||
/// to provide access to the current <see cref="HttpContext"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||||
private readonly string _claimType;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpContextAccessor">
|
||||
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
|
||||
/// </exception>
|
||||
public ClaimsIdentitySessionIsolationKeyProvider(
|
||||
IHttpContextAccessor? httpContextAccessor,
|
||||
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
|
||||
{
|
||||
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
|
||||
this._httpContextAccessor = httpContextAccessor;
|
||||
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the session isolation key from the current user's claims.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// 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 <see langword="null"/> if the claim
|
||||
/// is not present or the HTTP context is unavailable.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
|
||||
/// of the specified type exist, the first match is returned.
|
||||
/// </remarks>
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Claim? claim = this._httpContextAccessor?
|
||||
.HttpContext?
|
||||
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
|
||||
|
||||
return new ValueTask<string?>(claim?.Value);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
public class ClaimsIdentitySessionIsolationKeyProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the claim type to extract from the user's identity for session isolation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
|
||||
/// the user's name or unique identifier claim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common alternatives include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
|
||||
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
|
||||
/// <item><description>Custom claim types specific to your authentication provider</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn)</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Hosting ASP.NET Core</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context.</Description>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring AI hosting services in an <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
|
||||
/// to generate session isolation keys.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
|
||||
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
|
||||
/// <remarks>
|
||||
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
|
||||
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
|
||||
/// </remarks>
|
||||
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<IHttpContextAccessor>();
|
||||
|
||||
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> 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 <c>conversationId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>conversationId</c> 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
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
@@ -43,4 +75,35 @@ public abstract class AgentSessionStore
|
||||
AIAgent agent,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public virtual object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of type <typeparamref name="TService"/>.</summary>
|
||||
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSessionStore"/>,
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for agent session stores that delegate operations to an inner store
|
||||
/// instance while allowing for extensibility and customization.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="DelegatingAgentSessionStore"/> implements the decorator pattern for <see cref="AgentSessionStore"/>s,
|
||||
/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an
|
||||
/// underlying store.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStore"/> class with the specified inner
|
||||
/// store.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying session store instance that will handle the core operations.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
protected DelegatingAgentSessionStore(AgentSessionStore innerStore)
|
||||
{
|
||||
this.InnerStore = Throw.IfNull(innerStore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inner session store instance that receives delegated operations.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The underlying <see cref="AgentSessionStore"/> instance that handles core storage operations.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can use this property to access the inner session store for custom delegation scenarios
|
||||
/// or to forward operations with additional processing.
|
||||
/// </remarks>
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
|
||||
{
|
||||
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
|
||||
return builder;
|
||||
}
|
||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
|
||||
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
|
||||
@@ -29,12 +29,11 @@ public static class HostedAgentBuilderExtensions
|
||||
/// </summary>
|
||||
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
|
||||
/// <param name="store">The agent session store instance to register. Cannot be null.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
|
||||
@@ -44,16 +43,36 @@ public static class HostedAgentBuilderExtensions
|
||||
/// name.</param>
|
||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
||||
/// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime.</param>
|
||||
/// <param name="withIsolation">When <see langword="true"/>, wraps the session store with an <see cref="IsolationKeyScopedAgentSessionStore"/>
|
||||
/// to provide isolation-key-based scoping for sessions. Defaults to <see langword="true"/>.</param>
|
||||
/// <returns>The same host agent builder instance, enabling further configuration.</returns>
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton)
|
||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func<IServiceProvider, string, AgentSessionStore> 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<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
|
||||
|
||||
// Best efforts options getting
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
|
||||
if (options is null)
|
||||
{
|
||||
var optionsProvider = sp.GetService<IOptions<IsolationKeyScopedAgentSessionStoreOptions>>();
|
||||
options = optionsProvider?.Value;
|
||||
}
|
||||
|
||||
store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new());
|
||||
}
|
||||
|
||||
return store;
|
||||
}, lifetime);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
|
||||
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
|
||||
/// per logical partition (e.g., user, tenant, or composite key).
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
{
|
||||
private readonly SessionIsolationKeyProvider? _keyProvider;
|
||||
private readonly bool _strict;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
|
||||
/// <param name="keyProvider">
|
||||
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
|
||||
/// </param>
|
||||
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="innerStore"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public IsolationKeyScopedAgentSessionStore(
|
||||
AgentSessionStore innerStore,
|
||||
SessionIsolationKeyProvider? keyProvider,
|
||||
IsolationKeyScopedAgentSessionStoreOptions? options = null)
|
||||
: base(innerStore)
|
||||
{
|
||||
this._keyProvider = keyProvider;
|
||||
options ??= new IsolationKeyScopedAgentSessionStoreOptions();
|
||||
this._strict = options.Strict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The isolation key string, or <see langword="null"/> if no key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The provider returned <see langword="null"/> and strict mode is enabled.
|
||||
/// </exception>
|
||||
private async ValueTask<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// 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.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
|
||||
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="false"/>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Strict { get; set; } = true;
|
||||
}
|
||||
@@ -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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), 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
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
|
||||
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
|
||||
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
|
||||
/// scoped per principal. See the trust-model remarks on
|
||||
/// <see cref="AgentSessionStore"/> for the full background.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
|
||||
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
|
||||
/// to unscoped storage based on its configuration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class SessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the session isolation key for the current request or execution context.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation. The task result contains the isolation key string,
|
||||
/// or <see langword="null"/> if no key is available in the current context.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims,
|
||||
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
|
||||
/// the caller to decide on strict vs. pass-through behavior.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
+3
-7
@@ -75,18 +75,14 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
if (requireApproval)
|
||||
{
|
||||
// Create tool call content for approval request
|
||||
// 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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class with the given output tag.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The identifier of the executor that generated this event.</param>
|
||||
/// <param name="response">The agent response.</param>
|
||||
/// <param name="tag">The output tag to associate with this event.</param>
|
||||
public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : base(response, executorId, tag)
|
||||
{
|
||||
this.Response = Throw.IfNull(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class with the given output tags.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The identifier of the executor that generated this event.</param>
|
||||
/// <param name="response">The agent response.</param>
|
||||
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty.</param>
|
||||
public AgentResponseEvent(string executorId, AgentResponse response, IEnumerable<OutputTag>? tags) : base(response, executorId, tags)
|
||||
{
|
||||
this.Response = Throw.IfNull(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent response.
|
||||
/// </summary>
|
||||
|
||||
@@ -20,6 +20,28 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
|
||||
this.Update = Throw.IfNull(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class with the given output tag.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The identifier of the executor that generated this event.</param>
|
||||
/// <param name="update">The agent run response update.</param>
|
||||
/// <param name="tag">The output tag to associate with this event.</param>
|
||||
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : base(update, executorId, tag)
|
||||
{
|
||||
this.Update = Throw.IfNull(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class with the given output tags.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The identifier of the executor that generated this event.</param>
|
||||
/// <param name="update">The agent run response update.</param>
|
||||
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty.</param>
|
||||
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, IEnumerable<OutputTag>? tags) : base(update, executorId, tags)
|
||||
{
|
||||
this.Update = Throw.IfNull(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent run response update.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<ExecutorBinding> 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<string, string, ValueTask<ConcurrentEndExecutor>> 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 <see cref="AIAgent"/> must be capable of understanding those <see cref="AgentRunOptions"/> provided. If the agent
|
||||
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="SequentialWorkflowBuilder"/> with the given pipeline of <paramref name="agents"/>.</summary>
|
||||
/// <param name="agents">The sequence of agents to compose into a sequential workflow.</param>
|
||||
/// <returns>The builder for creating a sequential workflow.</returns>
|
||||
public static SequentialWorkflowBuilder CreateSequentialBuilderWith(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNull(agents);
|
||||
return new SequentialWorkflowBuilder(agents);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="ConcurrentWorkflowBuilder"/> with the given participating <paramref name="agents"/>.</summary>
|
||||
/// <param name="agents">The set of agents to compose into a concurrent workflow.</param>
|
||||
/// <returns>The builder for creating a concurrent workflow.</returns>
|
||||
public static ConcurrentWorkflowBuilder CreateConcurrentBuilderWith(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNull(agents);
|
||||
return new ConcurrentWorkflowBuilder(agents);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="MagenticWorkflowBuilder"/> with the given <paramref name="managerAgent"/>.</summary>
|
||||
/// <param name="managerAgent">The LLM-powered manager agent that coordinates the team.</param>
|
||||
/// <returns>The builder for creating a Magentic workflow.</returns>
|
||||
public static MagenticWorkflowBuilder CreateMagenticBuilderWith(AIAgent managerAgent)
|
||||
{
|
||||
Throw.IfNull(managerAgent);
|
||||
return new MagenticWorkflowBuilder(managerAgent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, List<EdgeInfo>> edges,
|
||||
HashSet<RequestPortInfo> requestPorts,
|
||||
string startExecutorId,
|
||||
HashSet<string>? outputExecutorIds)
|
||||
Dictionary<string, HashSet<OutputTag>>? 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<string, HashSet<OutputTag>>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public Dictionary<string, ExecutorInfo> Executors { get; }
|
||||
@@ -32,7 +33,15 @@ internal sealed class WorkflowInfo
|
||||
public TypeId? InputType { get; }
|
||||
public string StartExecutorId { get; }
|
||||
|
||||
public HashSet<string> OutputExecutorIds { get; }
|
||||
/// <summary>
|
||||
/// Map of executor id to the set of <see cref="OutputTag"/>s under which the executor is registered.
|
||||
/// An empty set means the executor is registered as a regular (untagged) output source.
|
||||
/// JSON shape: <c>{ "executorId": ["intermediate"], ... }</c>. Legacy payloads using the
|
||||
/// older <c>string[]</c> shape are read by <see cref="WorkflowInfoOutputExecutorsConverter"/> and
|
||||
/// each id is treated as registered with an empty tag set.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(WorkflowInfoOutputExecutorsConverter))]
|
||||
public Dictionary<string, HashSet<OutputTag>> 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<OutputTag>? tags) ||
|
||||
tags.Count != kvp.Value.Count ||
|
||||
!tags.SetEquals(kvp.Value)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
+122
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="WorkflowInfo.OutputExecutorIds"/> that supports both the new
|
||||
/// map shape (<c>{ "id": ["intermediate"] }</c>) and the legacy array shape
|
||||
/// (<c>["id1", "id2"]</c>). 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.
|
||||
/// </summary>
|
||||
internal sealed class WorkflowInfoOutputExecutorsConverter : JsonConverter<Dictionary<string, HashSet<OutputTag>>>
|
||||
{
|
||||
public override Dictionary<string, HashSet<OutputTag>> Read(
|
||||
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
Dictionary<string, HashSet<OutputTag>> 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<OutputTag> 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<string, HashSet<OutputTag>> value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (KeyValuePair<string, HashSet<OutputTag>> kvp in value)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
writer.WriteStartArray();
|
||||
foreach (OutputTag tag in kvp.Value)
|
||||
{
|
||||
writer.WriteStringValue(tag.Value);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="OrchestrationBuilderBase{TBuilder}.WithOutputFrom(IEnumerable{AIAgent})"/>
|
||||
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(IEnumerable{AIAgent})"/>
|
||||
/// at all suppresses these defaults.
|
||||
/// </remarks>
|
||||
public sealed class ConcurrentWorkflowBuilder : OrchestrationBuilderBase<ConcurrentWorkflowBuilder>
|
||||
{
|
||||
private readonly List<AIAgent> _agents = [];
|
||||
private Func<IList<List<ChatMessage>>, List<ChatMessage>>? _aggregator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="ConcurrentWorkflowBuilder"/> with the given participating
|
||||
/// <paramref name="agents"/>.
|
||||
/// </summary>
|
||||
public ConcurrentWorkflowBuilder(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNull(agents);
|
||||
foreach (AIAgent agent in agents)
|
||||
{
|
||||
Throw.IfNull(agent, nameof(agents));
|
||||
this._agents.Add(agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the aggregator function. If not called, defaults to returning the last message
|
||||
/// from each agent that produced at least one message.
|
||||
/// </summary>
|
||||
public ConcurrentWorkflowBuilder WithAggregator(Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
|
||||
{
|
||||
this._aggregator = Throw.IfNull(aggregator);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Builds the configured concurrent workflow.</summary>
|
||||
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<AIAgent, ExecutorBinding> 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<IList<List<ChatMessage>>, List<ChatMessage>> aggregator =
|
||||
this._aggregator ?? (static lists => (from list in lists where list.Count > 0 select list.Last()).ToList());
|
||||
|
||||
Func<string, string, ValueTask<ConcurrentEndExecutor>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<OutputTag>? tags)
|
||||
=> workflow.OutputExecutors.TryGetValue(sourceExecutorId, out tags);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide opt-in switches for in-development behavior changes that will become
|
||||
/// the default in a future major release. Each flag defaults to <see langword="false"/>
|
||||
/// and should be toggled once at application startup.
|
||||
/// </summary>
|
||||
public static class Futures
|
||||
{
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, <see cref="AgentResponse"/> and
|
||||
/// <see cref="AgentResponseUpdate"/> payloads yielded by an executor participate
|
||||
/// in the normal output-filter pipeline (i.e. they must be designated via
|
||||
/// <see cref="WorkflowBuilder.WithOutputFrom(ExecutorBinding[])"/> or
|
||||
/// <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, System.Collections.Generic.IEnumerable{ExecutorBinding})"/>
|
||||
/// to surface), and the resulting <see cref="WorkflowOutputEvent"/>s carry
|
||||
/// <see cref="WorkflowOutputEvent.Tags"/> reflecting that designation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <see langword="false"/> (the current default), the runner emits
|
||||
/// <see cref="AgentResponseEvent"/> and <see cref="AgentResponseUpdateEvent"/> unconditionally,
|
||||
/// bypassing the output filter (historical behavior). Lifecycle: opt-in today, marked
|
||||
/// <c>[Obsolete]</c> in v2.0.0 when the new behavior becomes default, and removed in v3.0.0.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Interaction with <see cref="WorkflowHostingExtensions.AsAIAgent"/>.</b> When this flag
|
||||
/// is <see langword="true"/>, <see cref="AgentResponseEvent"/> joins
|
||||
/// <see cref="AgentResponseUpdateEvent"/> in being forwarded out of the agent surface
|
||||
/// unconditionally — neither honors the host's <c>includeWorkflowOutputsInResponse</c>
|
||||
/// switch. That switch only governs the generic <see cref="WorkflowOutputEvent"/> path for
|
||||
/// non-AIAgent payloads. When this flag is <see langword="false"/>, the legacy asymmetry
|
||||
/// is preserved: <see cref="AgentResponseUpdateEvent"/> is always forwarded but
|
||||
/// <see cref="AgentResponseEvent"/> stays gated by <c>includeWorkflowOutputsInResponse</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool EnableAgentResponseOutputTaggingAndFiltering { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
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_";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
|
||||
/// </summary>
|
||||
@@ -48,12 +60,22 @@ public abstract class GroupChatManager
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Filters the chat history before it's passed to the next agent.
|
||||
/// Filters the messages broadcast to participants for the current turn.
|
||||
/// </summary>
|
||||
/// <param name="history">The chat history to filter.</param>
|
||||
/// <remarks>
|
||||
/// Under the broadcast model, each participant maintains its own per-agent session (history)
|
||||
/// through its <see cref="Specialized.AIAgentHostExecutor"/>. 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 <see cref="SelectNextAgentAsync"/> and
|
||||
/// <see cref="ShouldTerminateAsync"/>.
|
||||
/// </remarks>
|
||||
/// <param name="history">The new messages about to be broadcast to participants this turn.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The filtered chat history.</returns>
|
||||
/// <returns>The filtered message list to broadcast.</returns>
|
||||
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||
IReadOnlyList<ChatMessage> history,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
@@ -78,4 +100,125 @@ public abstract class GroupChatManager
|
||||
{
|
||||
this.IterationCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation is a no-op. Base-class state (currently
|
||||
/// <see cref="IterationCount"/>) is persisted automatically by the hosting
|
||||
/// <see cref="Specialized.GroupChatHost"/> before this method is invoked; subclasses do not
|
||||
/// need to call <c>base.OnCheckpointingAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The supplied <paramref name="context"/> is a wrapper that transparently prefixes every
|
||||
/// state key with <c>"GroupChatManager_"</c>, 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., <c>"next_index"</c>) without worrying about collisions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">A wrapped workflow context that scopes state keys to the
|
||||
/// <see cref="GroupChatManager"/> subclass namespace.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
protected virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="OnCheckpointingAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation is a no-op. Base-class state (currently
|
||||
/// <see cref="IterationCount"/>) is restored automatically by the hosting
|
||||
/// <see cref="Specialized.GroupChatHost"/> before this method is invoked; subclasses do not
|
||||
/// need to call <c>base.OnCheckpointRestoredAsync</c>. The supplied <paramref name="context"/>
|
||||
/// uses the same key-prefixing wrapper as <see cref="OnCheckpointingAsync"/>.
|
||||
/// </remarks>
|
||||
/// <param name="context">A wrapped workflow context that scopes state keys to the
|
||||
/// <see cref="GroupChatManager"/> subclass namespace.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
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<GroupChatManagerState>(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<string, string>? 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<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
=> this._inner.ReadStateAsync<T>(this.Wrap(key), scopeName, cancellationToken);
|
||||
|
||||
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
=> this._inner.ReadOrInitStateAsync(this.Wrap(key), initialStateFactory, scopeName, cancellationToken);
|
||||
|
||||
public async ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HashSet<string> 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<T>(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<string> 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<object>(rawKey, null, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string Wrap(string key) => this._prefix + Throw.IfNullOrEmpty(key);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <summary>
|
||||
/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow.
|
||||
/// </summary>
|
||||
public sealed class GroupChatWorkflowBuilder
|
||||
public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupChatWorkflowBuilder>
|
||||
{
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
|
||||
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
|
||||
private string _name = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
|
||||
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
|
||||
this._managerFactory = managerFactory;
|
||||
@@ -44,28 +42,6 @@ public sealed class GroupChatWorkflowBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the human-readable name for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the workflow.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the description for the workflow.
|
||||
/// </summary>
|
||||
/// <param name="description">The description of what the workflow does.</param>
|
||||
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
|
||||
public GroupChatWorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow"/> 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<AIAgent, ExecutorBinding> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Microsoft.Agents.AI.Workflows.ExecutorCo
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal static class DiagnosticConstants
|
||||
{
|
||||
public const string ExperimentalFeatureDiagnostic = "MAAIW001";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[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
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffWorkflowBuilder>(initialAgent)
|
||||
{
|
||||
}
|
||||
@@ -37,8 +32,8 @@ public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkfl
|
||||
/// <summary>
|
||||
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
|
||||
public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBuilder>
|
||||
where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
|
||||
{
|
||||
/// <summary>
|
||||
/// 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<TBuilder> 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<string>? _autonomousEnabledAgentIds;
|
||||
private readonly Dictionary<string, int> _autonomousTurnLimitsByAgentId = [];
|
||||
private readonly Dictionary<string, string> _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<IReadOnlyList<ChatMessage>, ValueTask<bool>>? _terminationCondition;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -99,20 +108,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
|
||||
public TBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
|
||||
public TBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
|
||||
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
|
||||
@@ -258,12 +253,204 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder)
|
||||
/// <summary>
|
||||
/// Adds the specified <paramref name="agents"/> as participants in the handoff workflow without
|
||||
/// defining handoff relationships for them.
|
||||
/// </summary>
|
||||
/// <param name="agents">The agents to add as participants.</param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
/// <remarks>
|
||||
/// Use this method when you want a participant to be part of the workflow but you have not
|
||||
/// explicitly defined handoff edges via <see cref="WithHandoff(AIAgent, AIAgent, string?)"/>.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public TBuilder AddParticipants(params IEnumerable<AIAgent> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables autonomous mode for the handoff workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Per-agent turn counting.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="turnLimit">
|
||||
/// The default maximum number of autonomous continuation iterations per agent per workflow
|
||||
/// turn. Applies to agents not listed in <paramref name="agentTurnLimits"/>. If
|
||||
/// <see langword="null"/>, defaults to
|
||||
/// <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit"/> (50).
|
||||
/// </param>
|
||||
/// <param name="continuationPrompt">
|
||||
/// The default user-role prompt fed to an agent on each autonomous continuation. Applies to
|
||||
/// agents not listed in <paramref name="agentContinuationPrompts"/>. If <see langword="null"/>,
|
||||
/// defaults to <see cref="HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt"/>.
|
||||
/// </param>
|
||||
/// <param name="agents">
|
||||
/// Optional allow-list restricting autonomous mode to a specific subset of agents. If
|
||||
/// <see langword="null"/> or empty, autonomous mode is enabled for <i>every</i> 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).
|
||||
/// </param>
|
||||
/// <param name="agentTurnLimits">
|
||||
/// Optional per-agent turn-limit overrides. Each entry's key is the agent and its value the
|
||||
/// turn limit that overrides <paramref name="turnLimit"/> for that agent. Agents not present
|
||||
/// fall back to the default.
|
||||
/// </param>
|
||||
/// <param name="agentContinuationPrompts">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
public TBuilder WithAutonomousMode(
|
||||
int? turnLimit = null,
|
||||
string? continuationPrompt = null,
|
||||
IEnumerable<AIAgent>? agents = null,
|
||||
IReadOnlyDictionary<AIAgent, int>? agentTurnLimits = null,
|
||||
IReadOnlyDictionary<AIAgent, string>? 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<string> 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<AIAgent, int> 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<AIAgent, string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a synchronous termination condition for the handoff workflow.
|
||||
/// </summary>
|
||||
/// <param name="terminationCondition">
|
||||
/// A predicate that receives the current conversation and returns <see langword="true"/> if the
|
||||
/// workflow should terminate (preventing further autonomous continuation). The synchronous
|
||||
/// predicate is wrapped and forwarded to the async overload.
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
/// <remarks>
|
||||
/// The termination condition is evaluated after the agent produces a response that does not
|
||||
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
|
||||
/// another autonomous continuation.
|
||||
/// </remarks>
|
||||
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, bool> terminationCondition)
|
||||
{
|
||||
Throw.IfNull(terminationCondition);
|
||||
|
||||
return this.WithTerminationCondition(
|
||||
messages => new ValueTask<bool>(terminationCondition(messages)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an asynchronous termination condition for the handoff workflow.
|
||||
/// </summary>
|
||||
/// <param name="terminationCondition">
|
||||
/// A predicate that receives the current conversation and asynchronously returns
|
||||
/// <see langword="true"/> if the workflow should terminate (preventing further autonomous
|
||||
/// continuation).
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
/// <remarks>
|
||||
/// The termination condition is evaluated after the agent produces a response that does not
|
||||
/// request a handoff. When it returns <see langword="true"/>, the workflow ends without invoking
|
||||
/// another autonomous continuation.
|
||||
/// </remarks>
|
||||
public TBuilder WithTerminationCondition(Func<IReadOnlyList<ChatMessage>, ValueTask<bool>> terminationCondition)
|
||||
{
|
||||
Throw.IfNull(terminationCondition);
|
||||
|
||||
this._terminationCondition = terminationCondition;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder, Dictionary<AIAgent, HashSet<HandoffTarget>> 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<TBuilder> where TBuilder : HandoffWorkfl
|
||||
|
||||
ExecutorBinding CreateFactoryBinding(AIAgent agent)
|
||||
{
|
||||
if (!this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
|
||||
if (!effectiveTargets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
|
||||
{
|
||||
handoffs = new();
|
||||
}
|
||||
@@ -287,10 +474,16 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
{
|
||||
foreach (HandoffTarget handoff in handoffs)
|
||||
{
|
||||
sb.AddCase<HandoffState>(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<HandoffState>(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<TBuilder> where TBuilder : HandoffWorkfl
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<AIAgent, HashSet<HandoffTarget>> 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<AIAgent, HashSet<HandoffTarget>> defaultTargets = [];
|
||||
|
||||
foreach (AIAgent source in this._allAgents)
|
||||
{
|
||||
HashSet<HandoffTarget> 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<ChatClientAgent>()?.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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Workflow"/> 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<TBuilder> 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<AIAgent, HashSet<HandoffTarget>> effectiveTargets = this._targets.Count == 0
|
||||
? this.BuildDefaultHandoffTargets()
|
||||
: this._targets;
|
||||
|
||||
// Create an factory-based ExecutorBinding for each agent.
|
||||
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder);
|
||||
Dictionary<string, ExecutorBinding> 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<TBuilder> 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<HandoffState>(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<AIAgent, ExecutorBinding> 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<ExecutorBinding> agentBindings = [.. executors.Values];
|
||||
if (agentBindings.Count > 0)
|
||||
{
|
||||
builder.WithIntermediateOutputFrom(agentBindings);
|
||||
}
|
||||
});
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OutputTag>? 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)
|
||||
|
||||
@@ -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.</param>
|
||||
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
/// <param name="Review">
|
||||
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
|
||||
/// </param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
|
||||
{
|
||||
internal bool IsApproved => this.Review.Count == 0;
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <summary>
|
||||
/// Maintains a ledger of progress made by the Magentic workflow.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <param name="managerAgent"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilderBase<MagenticWorkflowBuilder>
|
||||
{
|
||||
private readonly List<AIAgent> _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;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
|
||||
public MagenticWorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
|
||||
public MagenticWorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
|
||||
/// </summary>
|
||||
@@ -115,28 +97,29 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
ForwardIncomingMessages = false
|
||||
};
|
||||
|
||||
Dictionary<AIAgent, ExecutorBinding> teamMap = new(AIAgentIDEqualityComparer.Instance);
|
||||
List<ExecutorBinding> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Common fluent surface shared by every orchestration-style workflow builder:
|
||||
/// human-readable name + description, and the
|
||||
/// <see cref="WithOutputFrom"/> / <see cref="WithIntermediateOutputFrom"/> output-designation
|
||||
/// pair with memoized defaults-suppression semantics.
|
||||
/// </summary>
|
||||
/// <typeparam name="TBuilder">The concrete builder type, for fluent self-return.</typeparam>
|
||||
public abstract class OrchestrationBuilderBase<TBuilder>
|
||||
where TBuilder : OrchestrationBuilderBase<TBuilder>
|
||||
{
|
||||
/// <summary>Optional workflow name; applied to the inner <see cref="WorkflowBuilder"/> at <c>Build()</c>.</summary>
|
||||
protected string? Name { get; private set; }
|
||||
|
||||
/// <summary>Optional workflow description; applied to the inner <see cref="WorkflowBuilder"/> at <c>Build()</c>.</summary>
|
||||
protected string? Description { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Memoized output designations. <see langword="null"/> means the user has not made any
|
||||
/// explicit designation, and the orchestration-specific defaults will be applied at
|
||||
/// <c>Build()</c> time. A non-<see langword="null"/> (possibly empty) map means the user took
|
||||
/// control and only these designations will be replayed onto the inner
|
||||
/// <see cref="WorkflowBuilder"/>. An entry's value is the set of tags requested for the
|
||||
/// agent — an empty set encodes a terminal-only designation.
|
||||
/// </summary>
|
||||
protected Dictionary<AIAgent, HashSet<OutputTag>>? OutputDesignations { get; private set; }
|
||||
|
||||
/// <summary>Sets the human-readable name for the workflow.</summary>
|
||||
public TBuilder WithName(string name)
|
||||
{
|
||||
this.Name = name;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the description for the workflow.</summary>
|
||||
public TBuilder WithDescription(string description)
|
||||
{
|
||||
this.Description = description;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Designates the given <paramref name="agents"/> as sources of terminal workflow output.
|
||||
/// Calling any output-designation method (this or <see cref="WithIntermediateOutputFrom"/>)
|
||||
/// suppresses the orchestration-specific defaults: only the user-specified designations
|
||||
/// reach the inner <see cref="WorkflowBuilder"/>.
|
||||
/// </summary>
|
||||
public TBuilder WithOutputFrom(params IEnumerable<AIAgent> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Designates the given <paramref name="agents"/> as sources of <b>intermediate</b> workflow
|
||||
/// output. See <see cref="WithOutputFrom"/> for the defaults-suppression semantics.
|
||||
/// </summary>
|
||||
public TBuilder WithIntermediateOutputFrom(IEnumerable<AIAgent> 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<OutputTag>? tags))
|
||||
{
|
||||
tags = [];
|
||||
this.OutputDesignations[agent] = tags;
|
||||
}
|
||||
tags.Add(OutputTag.Intermediate);
|
||||
}
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the optional <see cref="Name"/> and <see cref="Description"/> to <paramref name="builder"/>.
|
||||
/// Subclasses should call this from their <c>Build()</c> implementation.
|
||||
/// </summary>
|
||||
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!);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the user's memoized output designations to <paramref name="builder"/>, or invokes
|
||||
/// <paramref name="applyDefaults"/> if the user made no explicit designation.
|
||||
/// </summary>
|
||||
/// <param name="builder">The inner <see cref="WorkflowBuilder"/>.</param>
|
||||
/// <param name="agentMap">Map from participating <see cref="AIAgent"/> to its bound executor.</param>
|
||||
/// <param name="orchestrationKind">Used in the not-a-participant error message (e.g. "sequential", "group chat").</param>
|
||||
/// <param name="applyDefaults">Action invoked when no explicit designation was made.</param>
|
||||
protected void ApplyOutputDesignations(
|
||||
WorkflowBuilder builder,
|
||||
IReadOnlyDictionary<AIAgent, ExecutorBinding> 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<OutputTag> tags = this.OutputDesignations[agent];
|
||||
if (tags.Count == 0)
|
||||
{
|
||||
builder.WithOutputFrom(binding);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (OutputTag tag in tags)
|
||||
{
|
||||
builder.WithOutputFrom(binding, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of output that a <see cref="WorkflowOutputEvent"/> represents.
|
||||
/// A thin <c>ChatRole</c>-style wrapper around a normalized string <see cref="Value"/>,
|
||||
/// with value equality and a closed set of well-known singletons (the constructor is
|
||||
/// <see langword="internal"/> for now).
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(OutputTagJsonConverter))]
|
||||
public readonly struct OutputTag : IEquatable<OutputTag>
|
||||
{
|
||||
/// <summary>
|
||||
/// The string identifier of the tag. Compared with ordinal equality.
|
||||
/// </summary>
|
||||
public string? Value { get; }
|
||||
|
||||
internal OutputTag(string value)
|
||||
{
|
||||
this.Value = Throw.IfNullOrEmpty(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag denoting an intermediate workflow output — emitted by executors
|
||||
/// registered via <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, System.Collections.Generic.IEnumerable{ExecutorBinding})"/>.
|
||||
/// Terminal (non-intermediate) outputs carry no tag.
|
||||
/// </summary>
|
||||
public static OutputTag Intermediate { get; } = new("intermediate");
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(OutputTag other) => string.Equals(this.Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is OutputTag other && this.Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => this.Value is null ? 0 : StringComparer.Ordinal.GetHashCode(this.Value);
|
||||
|
||||
/// <summary>Determines whether two <see cref="OutputTag"/> values are equal.</summary>
|
||||
public static bool operator ==(OutputTag left, OutputTag right) => left.Equals(right);
|
||||
|
||||
/// <summary>Determines whether two <see cref="OutputTag"/> values are not equal.</summary>
|
||||
public static bool operator !=(OutputTag left, OutputTag right) => !left.Equals(right);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => this.Value ?? string.Empty;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="OutputTag"/> that round-trips the underlying
|
||||
/// <see cref="OutputTag.Value"/> as a bare JSON string.
|
||||
/// </summary>
|
||||
internal sealed class OutputTagJsonConverter : JsonConverter<OutputTag>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -69,4 +69,23 @@ public class RoundRobinGroupChatManager : GroupChatManager
|
||||
base.Reset();
|
||||
this._nextIndex = 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.QueueStateUpdateAsync(StateKey, new RoundRobinGroupChatManagerState(this._nextIndex), cancellationToken: cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RoundRobinGroupChatManagerState? state = await context.ReadStateAsync<RoundRobinGroupChatManagerState>(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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Extensions.AI.ChatMessage"/>s as the workflow output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <see cref="OrchestrationBuilderBase{TBuilder}.WithOutputFrom(IEnumerable{AIAgent})"/>
|
||||
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(IEnumerable{AIAgent})"/>
|
||||
/// at all suppresses these defaults.
|
||||
/// </remarks>
|
||||
public sealed class SequentialWorkflowBuilder : OrchestrationBuilderBase<SequentialWorkflowBuilder>
|
||||
{
|
||||
private readonly List<AIAgent> _agents = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SequentialWorkflowBuilder"/> with the given pipeline
|
||||
/// of <paramref name="agents"/>.
|
||||
/// </summary>
|
||||
public SequentialWorkflowBuilder(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNull(agents);
|
||||
foreach (AIAgent agent in agents)
|
||||
{
|
||||
Throw.IfNull(agent, nameof(agents));
|
||||
this._agents.Add(agent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the configured sequential workflow.</summary>
|
||||
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<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
|
||||
List<ExecutorBinding> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<AIAgent, ExecutorBinding> _agentMap = agentMap;
|
||||
private readonly Func<IReadOnlyList<AIAgent>, 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<ChatMessage> _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<List<ChatMessage>>();
|
||||
|
||||
@@ -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<ChatMessage> filteredDelta = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
List<ChatMessage> 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<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
List<Task>? 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<ChatMessage> 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<List<ChatMessage>>(HistoryStateKey, cancellationToken: cancellationToken).ConfigureAwait(false) ?? [];
|
||||
this._currentSpeakerExecutorId = await context.ReadStateAsync<string?>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IReadOnlyList<ChatMessage>, ValueTask<bool>>? 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<TState>(string Key, string? ScopeName)
|
||||
}
|
||||
|
||||
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
internal sealed class HandoffAgentExecutor :
|
||||
StatefulExecutor<HandoffAgentHostState, HandoffState>
|
||||
{
|
||||
@@ -250,6 +260,7 @@ internal sealed class HandoffAgentExecutor :
|
||||
}
|
||||
|
||||
int newConversationBookmark = state.ConversationBookmark;
|
||||
List<ChatMessage>? 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)
|
||||
|
||||
@@ -8,18 +8,76 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
|
||||
internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
|
||||
/// <summary>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.</summary>
|
||||
/// <remarks>
|
||||
/// Autonomous-turn counters are tracked per source agent in <see cref="HandoffSharedState.AutonomousTurnsByAgent"/>.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<string>? _autonomousEnabledAgentIds;
|
||||
private readonly IReadOnlyDictionary<string, int> _autonomousTurnLimitsByAgentId;
|
||||
private readonly IReadOnlyDictionary<string, string> _autonomousContinuationPromptsByAgentId;
|
||||
|
||||
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
|
||||
HandoffConstants.HandoffSharedStateScope);
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
|
||||
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
|
||||
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
public HandoffEndExecutor(
|
||||
bool returnToPrevious,
|
||||
bool autonomousMode = false,
|
||||
int autonomousTurnLimit = HandoffWorkflowBuilderDefaults.DefaultAutonomousTurnLimit,
|
||||
string autonomousContinuationPrompt = HandoffWorkflowBuilderDefaults.DefaultAutonomousContinuationPrompt,
|
||||
HashSet<string>? autonomousEnabledAgentIds = null,
|
||||
IReadOnlyDictionary<string, int>? autonomousTurnLimitsByAgentId = null,
|
||||
IReadOnlyDictionary<string, string>? 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<string, int>();
|
||||
this._autonomousContinuationPromptsByAgentId = autonomousContinuationPromptsByAgentId ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
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<HandoffState>(
|
||||
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
// 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<HandoffState>();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, int>? 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the number of autonomous-mode continuation iterations consumed by each agent in the current
|
||||
/// "active" autonomous run. The counter is incremented by <see cref="HandoffEndExecutor"/> 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).
|
||||
/// </summary>
|
||||
[JsonInclude]
|
||||
public Dictionary<string, int> AutonomousTurnsByAgent { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+6
-7
@@ -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
|
||||
/// </summary>
|
||||
/// <param name="fullTaskLeger"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -40,7 +37,6 @@ public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : Magent
|
||||
/// Represents the creation of a new plan in response to a stall.
|
||||
/// </summary>
|
||||
/// <param name="fullTaskLeger"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -53,7 +49,6 @@ public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : Magentic
|
||||
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
|
||||
/// </summary>
|
||||
/// <param name="progressLedger"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -138,7 +133,6 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> 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<AIAgent> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public class Workflow
|
||||
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
|
||||
|
||||
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
|
||||
internal HashSet<string> OutputExecutors { get; init; } = [];
|
||||
internal Dictionary<string, HashSet<OutputTag>> OutputExecutors { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 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<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
|
||||
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Keys.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
|
||||
|
||||
Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false);
|
||||
IEnumerable<Type> yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields);
|
||||
|
||||
@@ -33,7 +33,7 @@ public class WorkflowBuilder
|
||||
private readonly HashSet<string> _unboundExecutors = [];
|
||||
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
|
||||
private readonly Dictionary<string, RequestPort> _requestPorts = [];
|
||||
private readonly HashSet<string> _outputExecutors = [];
|
||||
private readonly Dictionary<string, HashSet<OutputTag>> _outputExecutors = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly string _startExecutorId;
|
||||
private string? _name;
|
||||
@@ -97,22 +97,89 @@ public class WorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register executors as an output source. Executors can use <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values.
|
||||
/// By default, message handlers with a non-void return type will also be yielded, unless <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/>
|
||||
/// is set to <see langword="false"/>.
|
||||
/// Register executors as a source of terminal workflow outputs. Executors can use
|
||||
/// <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values; yielded values from
|
||||
/// registered executors are surfaced as <see cref="WorkflowOutputEvent"/> (or one of its
|
||||
/// subclasses) with an empty <see cref="WorkflowOutputEvent.Tags"/> set.
|
||||
/// By default, message handlers with a non-void return type will also be yielded, unless
|
||||
/// <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/> is set to <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// AIAgent payloads (<see cref="AgentResponse"/> / <see cref="AgentResponseUpdate"/>) only
|
||||
/// participate in this designation when
|
||||
/// <see cref="Futures.EnableAgentResponseOutputTaggingAndFiltering"/> is
|
||||
/// <see langword="true"/>; otherwise they are emitted unconditionally and untagged.
|
||||
/// </remarks>
|
||||
/// <param name="executors">The executors to register as output sources.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register executors as a source of workflow outputs carrying the given <paramref name="tag"/>.
|
||||
/// 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
|
||||
/// <see cref="WithOutputFrom(ExecutorBinding[])"/> overload was used).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Forward-looking surface for when the <see cref="OutputTag"/> constructor opens to
|
||||
/// user-defined tags. Today, prefer
|
||||
/// <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, IEnumerable{ExecutorBinding})"/>
|
||||
/// for the <see cref="OutputTag.Intermediate"/> case.
|
||||
/// </remarks>
|
||||
/// <param name="executors">The executors to register.</param>
|
||||
/// <param name="tag">The tag to apply to events yielded by the listed executors.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
public WorkflowBuilder WithOutputFrom(IEnumerable<ExecutorBinding> executors, OutputTag tag)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a single executor as a source of workflow outputs carrying the given <paramref name="tag"/>.
|
||||
/// Convenience overload for the single-executor case; equivalent to passing a one-element sequence
|
||||
/// to <see cref="WithOutputFrom(IEnumerable{ExecutorBinding}, OutputTag)"/>.
|
||||
/// </summary>
|
||||
/// <param name="executor">The executor to register.</param>
|
||||
/// <param name="tag">The tag to apply to events yielded by the executor.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
public WorkflowBuilder WithOutputFrom(ExecutorBinding executor, OutputTag tag)
|
||||
{
|
||||
Throw.IfNull(executor);
|
||||
|
||||
this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the executor id is present in <see cref="_outputExecutors"/>; if newly added,
|
||||
/// initializes with an empty tag set. Returns the tag set for the id (mutable).
|
||||
/// </summary>
|
||||
private HashSet<OutputTag> EnsureOutputExecutor(string executorId)
|
||||
{
|
||||
if (!this._outputExecutors.TryGetValue(executorId, out HashSet<OutputTag>? tags))
|
||||
{
|
||||
tags = [];
|
||||
this._outputExecutors[executorId] = tags;
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the human-readable name for the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -211,4 +211,28 @@ public static class WorkflowBuilderExtensions
|
||||
|
||||
return switchBuilder.ReduceToFanOut(builder, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register executors as a source of <b>intermediate</b> workflow outputs. The resulting
|
||||
/// <see cref="WorkflowOutputEvent"/>s carry <see cref="OutputTag.Intermediate"/> in their
|
||||
/// <see cref="WorkflowOutputEvent.Tags"/> set, and
|
||||
/// <see cref="WorkflowOutputEventExtensions.IsIntermediate(WorkflowOutputEvent)"/> returns
|
||||
/// <see langword="true"/>. 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AIAgent payloads (<see cref="AgentResponse"/> / <see cref="AgentResponseUpdate"/>) only
|
||||
/// participate in this designation when
|
||||
/// <see cref="Futures.EnableAgentResponseOutputTaggingAndFiltering"/> is
|
||||
/// <see langword="true"/>; otherwise they bypass the filter and are emitted untagged.
|
||||
/// </remarks>
|
||||
/// <param name="builder">The workflow builder to register executors on.</param>
|
||||
/// <param name="executors">The executors to register as intermediate output sources.</param>
|
||||
/// <returns>The <paramref name="builder"/>, enabling fluent configuration.</returns>
|
||||
public static WorkflowBuilder WithIntermediateOutputFrom(this WorkflowBuilder builder, IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
return builder.WithOutputFrom(executors, OutputTag.Intermediate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OutputTag> _tags;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class.
|
||||
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class with no tags.
|
||||
/// </summary>
|
||||
/// <param name="data">The output data.</param>
|
||||
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
|
||||
public WorkflowOutputEvent(object data, string executorId) : base(data)
|
||||
public WorkflowOutputEvent(object data, string executorId) : this(data, executorId, tags: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class carrying the
|
||||
/// given output tag.
|
||||
/// </summary>
|
||||
/// <param name="data">The output data.</param>
|
||||
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
|
||||
/// <param name="tag">The single output tag to associate with this event.</param>
|
||||
public WorkflowOutputEvent(object data, string executorId, OutputTag tag) : this(data, executorId, tags: new[] { tag })
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class carrying the
|
||||
/// given output tags (deduplicated).
|
||||
/// </summary>
|
||||
/// <param name="data">The output data.</param>
|
||||
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
|
||||
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty (the event is then untagged).</param>
|
||||
public WorkflowOutputEvent(object data, string executorId, IEnumerable<OutputTag>? tags) : base(data)
|
||||
{
|
||||
this.ExecutorId = executorId;
|
||||
this._tags = tags is null ? new HashSet<OutputTag>() : new HashSet<OutputTag>(tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -32,8 +58,21 @@ public class WorkflowOutputEvent : WorkflowEvent
|
||||
/// The unique identifier of the executor that yielded this output.
|
||||
/// </summary>
|
||||
[Obsolete("Use ExecutorId instead.")]
|
||||
[JsonIgnore]
|
||||
public string SourceId => this.ExecutorId;
|
||||
|
||||
/// <summary>
|
||||
/// The set of output tags associated with this event. Never <see langword="null"/>;
|
||||
/// empty for terminal/regular outputs. The presence of <see cref="OutputTag.Intermediate"/>
|
||||
/// marks this event as an intermediate output.
|
||||
/// </summary>
|
||||
public IEnumerable<OutputTag> Tags => this._tags;
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if this event carries the given tag.
|
||||
/// </summary>
|
||||
public bool HasTag(OutputTag tag) => this._tags.Contains(tag);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the underlying data is of the specified type or a derived type.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Extension helpers for inspecting <see cref="WorkflowOutputEvent"/> tag membership.
|
||||
/// </summary>
|
||||
public static class WorkflowOutputEventExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if the event carries
|
||||
/// <see cref="OutputTag.Intermediate"/> in its <see cref="WorkflowOutputEvent.Tags"/>.
|
||||
/// </summary>
|
||||
public static bool IsIntermediate(this WorkflowOutputEvent evt)
|
||||
{
|
||||
Throw.IfNull(evt);
|
||||
return evt.HasTag(OutputTag.Intermediate);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,11 +26,11 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>TodoList_Add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>TodoList_Complete</c> — Mark one or more todo items as complete by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_Remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_GetRemaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// <item><description><c>todos_add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>todos_complete</c> — Mark one or more todo items as complete by their IDs and reasons.</description></item>
|
||||
/// <item><description><c>todos_remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>todos_get_remaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>todos_get_all</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -53,11 +53,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use TodoList_GetRemaining to check what work is still pending.
|
||||
- Use TodoList_GetAll to review the full list including completed items.
|
||||
- Use TodoList_Remove to remove items that are no longer needed (supports one or many at once).
|
||||
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use todos_complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use todos_get_remaining to check what work is still pending.
|
||||
- Use todos_get_all to review the full list including completed items.
|
||||
- Use todos_remove to remove items that are no longer needed (supports one or many at once).
|
||||
""";
|
||||
|
||||
private readonly ProviderSessionState<TodoState> _sessionState;
|
||||
@@ -229,7 +229,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Add",
|
||||
Name = "todos_add",
|
||||
Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -267,7 +267,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Complete",
|
||||
Name = "todos_complete",
|
||||
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -297,7 +297,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Remove",
|
||||
Name = "todos_remove",
|
||||
Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetRemaining",
|
||||
Name = "todos_get_remaining",
|
||||
Description = "Retrieve the list of incomplete todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -341,7 +341,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetAll",
|
||||
Name = "todos_get_all",
|
||||
Description = "Retrieve the full list of todo items, both complete and incomplete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides contextual information about a discovered file to the
|
||||
/// <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> and
|
||||
/// <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicates.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillFilterContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkillFilterContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="skillName">The name of the skill (from SKILL.md frontmatter).</param>
|
||||
/// <param name="relativeFilePath">
|
||||
/// The path to the script or resource file relative to the skill directory (using forward slashes).
|
||||
/// </param>
|
||||
internal AgentFileSkillFilterContext(string skillName, string relativeFilePath)
|
||||
{
|
||||
this.SkillName = Throw.IfNullOrWhitespace(skillName);
|
||||
this.RelativeFilePath = Throw.IfNullOrWhitespace(relativeFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the skill as declared in the SKILL.md frontmatter.
|
||||
/// </summary>
|
||||
/// <example><c>unit-converter</c></example>
|
||||
public string SkillName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <c>run.py</c> for a script at skill root,
|
||||
/// <c>scripts/convert.js</c> for a nested script, or
|
||||
/// <c>references/guide.md</c> for a nested resource.
|
||||
/// </example>
|
||||
public string RelativeFilePath { get; }
|
||||
}
|
||||
@@ -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<string> _skillPaths;
|
||||
private readonly HashSet<string> _allowedResourceExtensions;
|
||||
private readonly HashSet<string> _allowedScriptExtensions;
|
||||
private readonly IReadOnlyList<string> _scriptDirectories;
|
||||
private readonly IReadOnlyList<string> _resourceDirectories;
|
||||
private readonly int _searchDepth;
|
||||
private readonly Func<AgentFileSkillFilterContext, bool>? _scriptFilter;
|
||||
private readonly Func<AgentFileSkillFilterContext, bool>? _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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> to scan different or
|
||||
/// additional directories, including <c>"."</c> for the skill root itself.
|
||||
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
|
||||
/// If a <see cref="AgentFileSkillsSourceOptions.ResourceFilter"/> predicate is configured, files
|
||||
/// that do not satisfy it are excluded.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillResource> DiscoverResourceFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
var resources = new List<AgentFileSkillResource>();
|
||||
|
||||
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<AgentFileSkillResource> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
|
||||
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> to scan different or
|
||||
/// additional directories, including <c>"."</c> for the skill root itself.
|
||||
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
|
||||
/// If a <see cref="AgentFileSkillsSourceOptions.ScriptFilter"/> predicate is configured, files
|
||||
/// that do not satisfy it are excluded.
|
||||
/// </remarks>
|
||||
private List<AgentFileSkillScript> DiscoverScriptFiles(string skillDirectoryFullPath, string skillName)
|
||||
{
|
||||
var scripts = new List<AgentFileSkillScript>();
|
||||
|
||||
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<AgentFileSkillScript> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -542,6 +563,31 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
|
||||
return false;
|
||||
}
|
||||
|
||||
#if !NET
|
||||
/// <summary>
|
||||
/// Best-effort directory enumeration for target frameworks without
|
||||
/// <c>EnumerationOptions.IgnoreInaccessible</c> 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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
}
|
||||
}
|
||||
#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<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> 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);
|
||||
}
|
||||
|
||||
@@ -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<string>? AllowedScriptExtensions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative directory paths to scan for script files within each skill directory.
|
||||
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
|
||||
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
|
||||
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
|
||||
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
|
||||
/// rejected.
|
||||
/// When <see langword="null"/>, defaults to <c>scripts</c> (per the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
|
||||
/// 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 <c>1</c> searches only the skill root directory. A value of <c>2</c> searches the root
|
||||
/// and one level of subdirectories.
|
||||
/// When <see langword="null"/>, the source uses the default depth of <c>2</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ScriptDirectories { get; set; }
|
||||
/// <remarks>
|
||||
/// Must be greater than or equal to <c>1</c>; lower values are rejected by the constructor.
|
||||
/// </remarks>
|
||||
public int? SearchDepth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets relative directory paths to scan for resource files within each skill directory.
|
||||
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
|
||||
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
|
||||
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
|
||||
/// normalized automatically; paths containing <c>".."</c> segments or absolute paths are
|
||||
/// rejected.
|
||||
/// When <see langword="null"/>, defaults to <c>references</c> and <c>assets</c> (per the
|
||||
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
|
||||
/// When set, replaces the defaults entirely.
|
||||
/// Gets or sets a predicate that filters discovered script files.
|
||||
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
|
||||
/// and the file's path relative to the skill directory.
|
||||
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
|
||||
/// When <see langword="null"/>, all scripts matching the allowed extensions are included.
|
||||
/// </summary>
|
||||
public IEnumerable<string>? ResourceDirectories { get; set; }
|
||||
public Func<AgentFileSkillFilterContext, bool>? ScriptFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a predicate that filters discovered resource files.
|
||||
/// The predicate receives an <see cref="AgentFileSkillFilterContext"/> containing the skill's name
|
||||
/// and the file's path relative to the skill directory.
|
||||
/// Return <see langword="true"/> to include the file or <see langword="false"/> to exclude it.
|
||||
/// When <see langword="null"/>, all resources matching the allowed extensions are included.
|
||||
/// </summary>
|
||||
public Func<AgentFileSkillFilterContext, bool>? ResourceFilter { get; set; }
|
||||
}
|
||||
|
||||
@@ -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<UsageContent>(update1.Contents[0]);
|
||||
UsageContent usageContent = (UsageContent)update1.Contents[0];
|
||||
Assert.Equal(100, usageContent.Details.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAsStructuredOutputWithJSOSuccess()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
var done = Assert.Single(events.OfType<ResponseOutputItemDoneEvent>());
|
||||
|
||||
var addedOutput = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
|
||||
var doneOutput = Assert.IsType<OutputItemFunctionToolCallOutput>(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()
|
||||
|
||||
+2
@@ -8,6 +8,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
|
||||
+251
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
|
||||
/// </summary>
|
||||
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<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
|
||||
/// </summary>
|
||||
public ClaimsIdentitySessionIsolationKeyProviderTests()
|
||||
{
|
||||
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UsesDefaultOptionsWhenNull()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts null IHttpContextAccessor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
|
||||
{
|
||||
// Act & Assert - should not throw
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
Assert.NotNull(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotEmpty()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentException when claimType is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresClaimType_NotWhitespace()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("options.ClaimType", () =>
|
||||
new ClaimsIdentitySessionIsolationKeyProvider(
|
||||
this._httpContextAccessorMock.Object,
|
||||
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSessionIsolationKeyAsync Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor returns null HttpContext.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify behavior when HttpContextAccessor itself is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
+400
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DelegatingAgentSessionStore"/> class.
|
||||
/// </summary>
|
||||
public class DelegatingAgentSessionStoreTests
|
||||
{
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly TestDelegatingAgentSessionStore _delegatingStore;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public DelegatingAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
// Setup inner store mock
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () => new TestDelegatingAgentSessionStore(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor sets the inner store correctly.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncDelegatesToInnerStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedCancellationToken = new CancellationToken();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.Is<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<CancellationToken>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync delegates to inner store with correct parameters.
|
||||
/// </summary>
|
||||
[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<AIAgent>(a => a == this._agentMock.Object),
|
||||
It.Is<string>(c => c == ExpectedConversationId),
|
||||
It.Is<AgentSession>(s => s == expectedSession),
|
||||
It.Is<CancellationToken>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync awaits the inner store's result before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionAsyncAwaitsInnerStoreResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var taskCompletionSource = new TaskCompletionSource<AgentSession>();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<AgentSession>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync awaits the inner store's completion before returning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ExpectedConversationId = "test-conversation-id";
|
||||
var expectedSession = new TestAgentSession();
|
||||
var taskCompletionSource = new TaskCompletionSource();
|
||||
|
||||
var innerStoreMock = new Mock<AgentSessionStore>();
|
||||
innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting the exact type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForExactType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting a base type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForBaseType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns itself when requesting AgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsItselfForAgentSessionStoreType()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(AgentSessionStore));
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains to inner store when type is not satisfied by outer store.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through multiple delegation layers.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can find a store in the middle of the delegation chain.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when the requested type is not found in the chain.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService returns null when a service key is provided but not matched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsNullWhenServiceKeyProvided()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService throws ArgumentNullException when serviceType is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceThrowsWhenServiceTypeIsNull() =>
|
||||
Assert.Throws<ArgumentNullException>("serviceType", () => this._delegatingStore.GetService(null!));
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsItself()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<TestDelegatingAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(this._delegatingStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method chains to inner store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericChainsToInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var innerStore = new ConcreteAgentSessionStore();
|
||||
var delegatingStore = new TestDelegatingAgentSessionStore(innerStore);
|
||||
|
||||
// Act
|
||||
var result = delegatingStore.GetService<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(innerStore, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService generic method returns null when type not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceGenericReturnsNullWhenTypeNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = this._delegatingStore.GetService<string>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Implementation
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of DelegatingAgentSessionStore for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore)
|
||||
{
|
||||
public new AgentSessionStore InnerStore => base.InnerStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Another delegating store implementation for testing multi-layer chains.
|
||||
/// </summary>
|
||||
private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore);
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> 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
|
||||
}
|
||||
+430
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="IsolationKeyScopedAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
public class IsolationKeyScopedAgentSessionStoreTests
|
||||
{
|
||||
private const string TestIsolationKey = "test-key";
|
||||
private const string TestConversationId = "test-conversation-id";
|
||||
|
||||
private readonly Mock<AgentSessionStore> _innerStoreMock;
|
||||
private readonly Mock<AIAgent> _agentMock;
|
||||
private readonly AgentSession _testSession;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IsolationKeyScopedAgentSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public IsolationKeyScopedAgentSessionStoreTests()
|
||||
{
|
||||
this._innerStoreMock = new Mock<AgentSessionStore>();
|
||||
this._agentMock = new Mock<AIAgent>();
|
||||
this._testSession = new TestAgentSession();
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.GetSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(this._testSession);
|
||||
|
||||
this._innerStoreMock
|
||||
.Setup(x => x.SaveSessionAsync(It.IsAny<AIAgent>(), It.IsAny<string>(), It.IsAny<AgentSession>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor throws ArgumentNullException when innerStore is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiresInnerStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("innerStore", () =>
|
||||
new IsolationKeyScopedAgentSessionStore(null!, provider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor uses default options when options is null.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetSessionAsync returns the session from the inner store.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync scopes the conversation ID with the isolation key.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
|
||||
|
||||
Assert.Contains("Session isolation key is required", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that SaveSessionAsync does not throw when key is null in non-strict mode.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that colons in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that backslashes in the isolation key are escaped.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that both backslashes and colons in the isolation key are escaped correctly.
|
||||
/// </summary>
|
||||
[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<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verify that different isolation keys result in different scoped conversation IDs.
|
||||
/// </summary>
|
||||
[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<AIAgent>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIAgent, string, CancellationToken>((_, 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
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
|
||||
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
|
||||
|
||||
// Act
|
||||
var result = store.GetService<IsolationKeyScopedAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(store, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetService chains through to find inner store types.
|
||||
/// </summary>
|
||||
[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<ConcreteAgentSessionStore>();
|
||||
|
||||
// Assert
|
||||
Assert.Same(concreteInnerStore, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete (non-delegating) session store for testing GetService chaining.
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> 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
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
|
||||
/// </summary>
|
||||
public class SessionIsolationKeyProviderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return a non-null isolation key.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a concrete provider can return null when no key is available.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestSessionIsolationKeyProvider(null);
|
||||
|
||||
// Act
|
||||
string? result = await provider.GetSessionIsolationKeyAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that cancellation token is passed through to the provider implementation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TestCancellableSessionIsolationKeyProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<TaskCanceledException>(
|
||||
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
|
||||
}
|
||||
|
||||
#region Test Implementations
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
|
||||
/// </summary>
|
||||
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
private readonly string? _key;
|
||||
|
||||
public TestSessionIsolationKeyProvider(string? key)
|
||||
{
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask<string?>(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that respects cancellation tokens.
|
||||
/// </summary>
|
||||
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
|
||||
{
|
||||
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return "key";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+20
-31
@@ -111,10 +111,9 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
|
||||
public async Task GetSkillsAsync_ScriptsInRootAndSubdirectories_AreDiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — scripts outside configured directories are not discovered; only files directly
|
||||
// inside the configured directory are picked up (no subdirectory recursion)
|
||||
// Arrange — with default depth=2, scripts in root and immediate subdirectories are discovered
|
||||
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
|
||||
CreateFile(skillDir, "convert.py", "print('root')");
|
||||
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
|
||||
@@ -123,9 +122,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
|
||||
// Assert — both root and subdirectory scripts are discovered
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("convert.py"));
|
||||
Assert.NotNull(await skills[0].GetScriptAsync("convert.py"));
|
||||
Assert.NotNull(await skills[0].GetScriptAsync("tools/helper.sh"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,13 +225,13 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync()
|
||||
public async Task GetSkillsAsync_DeepScript_DiscoveredWithHigherDepthAsync()
|
||||
{
|
||||
// Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3)
|
||||
// Arrange — script at depth 4 (f1/f2/f3/run.py) discovered with SearchDepth=5
|
||||
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
|
||||
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] });
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
@@ -243,36 +243,25 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
|
||||
Assert.Equal("f1/f2/f3/run.py", nestedScript!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./scripts")]
|
||||
[InlineData("./scripts/f1")]
|
||||
[InlineData("./scripts/f1", "./f2")]
|
||||
public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories)
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptFilter_ExcludesFilteredScriptsAsync()
|
||||
{
|
||||
// Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix;
|
||||
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
|
||||
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
|
||||
CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')");
|
||||
}
|
||||
|
||||
// Arrange — ScriptFilter excludes scripts in the "f2" subdirectory
|
||||
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Filter test", "Body.");
|
||||
CreateFile(skillDir, "scripts/run.py", "print('scripts')");
|
||||
CreateFile(skillDir, "f2/run.py", "print('f2')");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = directories });
|
||||
new AgentFileSkillsSourceOptions { ScriptFilter = ctx => !ctx.RelativeFilePath.StartsWith("f2/", StringComparison.OrdinalIgnoreCase) });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync(CancellationToken.None);
|
||||
|
||||
// Assert — scripts are discovered with names identical to using directories without "./"
|
||||
// Assert — only scripts/ script is included; f2/ is excluded by filter
|
||||
Assert.Single(skills);
|
||||
foreach (string directory in directories)
|
||||
{
|
||||
string expectedName = $"{directory.Substring(2)}/run.py";
|
||||
var script = await skills[0].GetScriptAsync(expectedName);
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal(expectedName, script!.Name);
|
||||
}
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
Assert.Null(await skills[0].GetScriptAsync("f2/run.py"));
|
||||
}
|
||||
|
||||
private static string CreateSkillDir(string root, string name, string description, string body)
|
||||
|
||||
+94
-130
@@ -425,9 +425,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
|
||||
// Arrange — resource files directly in the skill directory are discovered with default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||||
@@ -440,29 +440,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
|
||||
{
|
||||
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
|
||||
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
|
||||
File.WriteAllText(Path.Combine(skillDir, "config.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — both root-level resource files (and SKILL.md excluded) should be discovered
|
||||
// Assert — root-level files are discovered by default (depth=2 includes root)
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Equal(2, skill.GetTestResources()!.Count);
|
||||
@@ -471,9 +449,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
|
||||
public void Constructor_SearchDepthBelowOne_Throws()
|
||||
{
|
||||
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
|
||||
// Arrange / Act / Assert — SearchDepth must be >= 1
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 0 }));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = -1 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — resource in any subdirectory is discovered with default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
|
||||
string customDir = Path.Combine(skillDir, "docs");
|
||||
Directory.CreateDirectory(customDir);
|
||||
@@ -486,15 +477,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — non-spec directories are not scanned by default
|
||||
// Assert — subdirectory files are discovered by default
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
|
||||
public async Task GetSkillsAsync_ResourceFilter_ExcludesFilteredFilesAsync()
|
||||
{
|
||||
// Arrange — custom ResourceDirectories replaces the spec defaults
|
||||
// Arrange — ResourceFilter excludes files in the "docs" subdirectory
|
||||
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
|
||||
string customDir = Path.Combine(skillDir, "docs");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
@@ -506,16 +498,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
|
||||
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => !ctx.RelativeFilePath.StartsWith("docs/", StringComparison.OrdinalIgnoreCase) });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only docs/ is scanned; references/ is NOT scanned
|
||||
// Assert — only references/ resource is included; docs/ is excluded by filter
|
||||
Assert.Single(skills);
|
||||
var skill = skills[0];
|
||||
Assert.Single(skill.GetTestResources()!);
|
||||
Assert.Equal("docs/readme.md", skill.GetTestResources()![0].Name);
|
||||
Assert.Equal("references/ref.md", skill.GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -755,9 +747,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
|
||||
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsSymlinkedDirectoryAsync()
|
||||
{
|
||||
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
|
||||
// Arrange — "sub" directory is a symlink pointing outside the skill directory.
|
||||
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
|
||||
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
@@ -783,7 +775,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
var source = new AgentFileSkillsSource(
|
||||
this._testRoot,
|
||||
s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 4 });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
@@ -957,54 +949,32 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
Assert.Null(fm.Metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..")]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("sub/../escape")]
|
||||
[InlineData("/absolute")]
|
||||
[InlineData("\\absolute")]
|
||||
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_SearchDepthOne_OnlyRootFilesDiscoveredAsync()
|
||||
{
|
||||
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
|
||||
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
|
||||
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
|
||||
// Arrange — with SearchDepth = 1, only root-level files are discovered
|
||||
string skillDir = Path.Combine(this._testRoot, "depth-one-skill");
|
||||
string scriptsDir = Path.Combine(skillDir, "scripts");
|
||||
Directory.CreateDirectory(scriptsDir);
|
||||
File.WriteAllText(Path.Combine(scriptsDir, "run.py"), "print('hello')");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: depth-one-skill\ndescription: Depth one\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 1 });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(source1);
|
||||
Assert.NotNull(source2);
|
||||
}
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
|
||||
{
|
||||
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
|
||||
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("scripts")]
|
||||
[InlineData("my-scripts")]
|
||||
[InlineData("sub/directory")]
|
||||
[InlineData(".")]
|
||||
[InlineData("./scripts")]
|
||||
[InlineData("./scripts/f1")]
|
||||
[InlineData("my..scripts")]
|
||||
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
|
||||
Assert.NotNull(source);
|
||||
// Assert — scripts in subdirectories are NOT discovered at depth 1
|
||||
Assert.Single(skills);
|
||||
Assert.Null(await skills[0].GetScriptAsync("scripts/run.py"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
|
||||
public async Task GetSkillsAsync_ResourceInSubdirectory_DiscoveredWithDefaultDepthAsync()
|
||||
{
|
||||
// Arrange — "references" and "./references" refer to the same directory;
|
||||
// after normalization they should be deduplicated so resources appear only once.
|
||||
// Arrange — resources in a subdirectory are discovered by default (depth=2)
|
||||
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
@@ -1012,45 +982,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — only one copy of the resource despite two equivalent directory entries
|
||||
// Assert — resource is discovered once
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/FAQ.md", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
|
||||
public async Task GetSkillsAsync_ScriptInSubdirectory_DiscoveredWithDefaultDepthAsync()
|
||||
{
|
||||
// Arrange — "references/" should be normalized to "references"
|
||||
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — trailing slash variant deduplicated
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
|
||||
{
|
||||
// Arrange — ".\\scripts" should be normalized to "scripts"
|
||||
// Arrange — scripts in a subdirectory are discovered by default (depth=2)
|
||||
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
|
||||
string scriptsDir = Path.Combine(skillDir, "scripts");
|
||||
Directory.CreateDirectory(scriptsDir);
|
||||
@@ -1058,50 +1004,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — backslash variant deduplicated
|
||||
// Assert — script is discovered
|
||||
Assert.Single(skills);
|
||||
var script = await skills[0].GetScriptAsync("scripts/run.py");
|
||||
Assert.NotNull(script);
|
||||
Assert.Equal("scripts/run.py", script!.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./references")]
|
||||
[InlineData("./assets/docs")]
|
||||
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceFilterWhitelist_OnlyMatchingFilesDiscoveredAsync()
|
||||
{
|
||||
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
|
||||
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
|
||||
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
|
||||
// Arrange — ResourceFilter acts as whitelist: only references/ paths included
|
||||
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
|
||||
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(targetDir);
|
||||
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
|
||||
string refsDir = Path.Combine(skillDir, "references");
|
||||
string assetsDir = Path.Combine(skillDir, "assets");
|
||||
Directory.CreateDirectory(refsDir);
|
||||
Directory.CreateDirectory(assetsDir);
|
||||
File.WriteAllText(Path.Combine(refsDir, "data.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(assetsDir, "image.txt"), "data");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
|
||||
new AgentFileSkillsSourceOptions { ResourceFilter = ctx => ctx.RelativeFilePath.StartsWith("references/", StringComparison.OrdinalIgnoreCase) });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — the resource is discovered with a name identical to using the directory without "./"
|
||||
// Assert — only the references/ resource is included
|
||||
Assert.Single(skills);
|
||||
Assert.Single(skills[0].GetTestResources()!);
|
||||
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].GetTestResources()![0].Name);
|
||||
Assert.Equal("references/data.json", skills[0].GetTestResources()![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
|
||||
public async Task GetSkillsAsync_DeepResource_NotDiscoveredWithDefaultDepthAsync()
|
||||
{
|
||||
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
|
||||
// Arrange — resource at depth 3 (f1/f2/f3/data.json) exceeds default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
|
||||
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
|
||||
Directory.CreateDirectory(nestedDir);
|
||||
@@ -1109,8 +1053,29 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — resource at depth 4 is NOT discovered with default depth=2
|
||||
Assert.Single(skills);
|
||||
Assert.Empty(skills[0].GetTestResources()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_DeepResource_DiscoveredWithHigherDepthAsync()
|
||||
{
|
||||
// Arrange — resource at depth 4 (f1/f2/f3/data.json) discovered with SearchDepth=5
|
||||
string skillDir = Path.Combine(this._testRoot, "deep-res-skill");
|
||||
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
|
||||
Directory.CreateDirectory(nestedDir);
|
||||
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: deep-res-skill\ndescription: Deep resource\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
|
||||
new AgentFileSkillsSourceOptions { SearchDepth = 5 });
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
@@ -1171,22 +1136,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
|
||||
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredByDefaultAsync()
|
||||
{
|
||||
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
|
||||
// Arrange — script file directly in the skill directory is discovered with default depth=2
|
||||
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
|
||||
Directory.CreateDirectory(skillDir);
|
||||
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
|
||||
File.WriteAllText(
|
||||
Path.Combine(skillDir, "SKILL.md"),
|
||||
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
|
||||
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
|
||||
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
|
||||
|
||||
// Act
|
||||
var skills = await source.GetSkillsAsync();
|
||||
|
||||
// Assert — script at the skill root should be discovered
|
||||
// Assert — script at the skill root is discovered by default
|
||||
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "root-script-skill");
|
||||
Assert.NotNull(skill);
|
||||
var script = await skill.GetScriptAsync("run.py");
|
||||
|
||||
@@ -51,7 +51,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -75,7 +75,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -111,8 +111,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -131,8 +131,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -156,7 +156,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
|
||||
// Act
|
||||
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
|
||||
@@ -173,8 +173,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
|
||||
|
||||
// Act
|
||||
@@ -200,8 +200,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -220,8 +220,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -244,7 +244,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
|
||||
// Act
|
||||
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
|
||||
@@ -265,9 +265,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "TodoList_GetRemaining");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "todos_get_remaining");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -295,9 +295,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "todos_get_all");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -332,12 +332,12 @@ public class TodoProviderTests
|
||||
|
||||
// Act — first invocation adds a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Persisted", Description = null } } });
|
||||
|
||||
// Second invocation should see the same state
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_GetAll");
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "todos_get_all");
|
||||
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -364,7 +364,7 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
|
||||
@@ -393,8 +393,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -556,8 +556,8 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add some todos (one with a description to cover that branch)
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput>
|
||||
@@ -622,7 +622,7 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
|
||||
@@ -687,7 +687,7 @@ public class TodoProviderTests
|
||||
|
||||
// Add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
|
||||
@@ -725,8 +725,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Act — launch multiple concurrent adds
|
||||
var tasks = Enumerable.Range(0, 10).Select(i =>
|
||||
@@ -760,9 +760,9 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Add initial items
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
|
||||
+147
@@ -1,9 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
@@ -290,6 +292,151 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
await this.ExecuteTestAsync(model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolApprovalRequestExcludesTransportHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolApprovalRequestExcludesTransportHeadersAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
serverLabel: TestServerLabel,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true,
|
||||
headerKey: "Authorization",
|
||||
headerValue: "Bearer super-secret-token");
|
||||
MockMcpToolProvider mockProvider = new();
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
ExternalInputRequest? capturedRequest = null;
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(
|
||||
[
|
||||
action,
|
||||
new DelegateActionExecutor<ExternalInputRequest>(
|
||||
InvokeMcpToolExecutor.Steps.ExternalInput(action.Id),
|
||||
this.State,
|
||||
CaptureRequestAsync)
|
||||
],
|
||||
isDiscrete: false);
|
||||
|
||||
// Assert - the approval event must not carry any transport headers (e.g. Authorization).
|
||||
Assert.NotNull(capturedRequest);
|
||||
ToolApprovalRequestContent approvalRequest =
|
||||
capturedRequest!.AgentResponse.Messages
|
||||
.SelectMany(message => message.Contents)
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Single();
|
||||
|
||||
AdditionalPropertiesDictionary? additionalProperties = approvalRequest.ToolCall.AdditionalProperties;
|
||||
Assert.True(additionalProperties is null || additionalProperties.Count == 0);
|
||||
|
||||
// Defense in depth: the credential value must not appear anywhere in the serialized approval content.
|
||||
string serializedApproval = System.Text.Json.JsonSerializer.Serialize(capturedRequest.AgentResponse);
|
||||
Assert.DoesNotContain("super-secret-token", serializedApproval);
|
||||
|
||||
ValueTask CaptureRequestAsync(IWorkflowContext context, ExternalInputRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
capturedRequest = request;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolInvocationForwardsHeadersToTransportAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string HeaderKey = "Authorization";
|
||||
const string HeaderValue = "Bearer super-secret-token";
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolInvocationForwardsHeadersToTransportAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
serverLabel: TestServerLabel,
|
||||
toolName: TestToolName,
|
||||
requireApproval: false,
|
||||
headerKey: HeaderKey,
|
||||
headerValue: HeaderValue);
|
||||
|
||||
IDictionary<string, string>? capturedHeaders = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider
|
||||
.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, _, _, headers, _, _) => capturedHeaders = headers)
|
||||
.ReturnsAsync(new McpServerToolResultContent("mock-call-id") { Outputs = [new TextContent("ok")] });
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert - headers remain available to the actual transport invocation.
|
||||
Assert.NotNull(capturedHeaders);
|
||||
Assert.True(capturedHeaders!.TryGetValue(HeaderKey, out string? forwardedValue));
|
||||
Assert.Equal(HeaderValue, forwardedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolApprovedCaptureResponseForwardsHeadersToTransportAsync()
|
||||
{
|
||||
// Arrange - exercises the post-approval CaptureResponseAsync resume path to prove the
|
||||
// fix did not regress header forwarding on the path that the vulnerability actually targets.
|
||||
this.State.InitializeSystem();
|
||||
const string HeaderKey = "Authorization";
|
||||
const string HeaderValue = "Bearer super-secret-token";
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolApprovedCaptureResponseForwardsHeadersToTransportAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
serverLabel: TestServerLabel,
|
||||
toolName: TestToolName,
|
||||
requireApproval: true,
|
||||
headerKey: HeaderKey,
|
||||
headerValue: HeaderValue);
|
||||
|
||||
IDictionary<string, string>? capturedHeaders = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider
|
||||
.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, _, _, headers, _, _) => capturedHeaders = headers)
|
||||
.ReturnsAsync(new McpServerToolResultContent("mock-call-id") { Outputs = [new TextContent("ok")] });
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
Mock<IWorkflowContext> mockContext = new(MockBehavior.Loose);
|
||||
|
||||
// Build an approved response matching this action's request id.
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerLabel);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Act - call CaptureResponseAsync directly so the post-approval branch actually executes.
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - headers reach the transport invocation on the approved path.
|
||||
Assert.NotNull(capturedHeaders);
|
||||
Assert.True(capturedHeaders!.TryGetValue(HeaderKey, out string? forwardedValue));
|
||||
Assert.Equal(HeaderValue, forwardedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithEmptyHeaderValueAsync()
|
||||
{
|
||||
|
||||
+120
-284
@@ -4,12 +4,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
|
||||
@@ -17,325 +14,164 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests targeting the static <see cref="AgentWorkflowBuilder"/> helper surface —
|
||||
/// <see cref="AgentWorkflowBuilder.BuildSequential(IEnumerable{AIAgent})"/>,
|
||||
/// <see cref="AgentWorkflowBuilder.BuildConcurrent(IEnumerable{AIAgent}, Func{IList{List{ChatMessage}}, List{ChatMessage}})"/>,
|
||||
/// and the various <c>Create*BuilderWith</c> factories. Per-builder unit tests live in their own
|
||||
/// files (<see cref="SequentialWorkflowBuilderTests"/>, <see cref="ConcurrentWorkflowBuilderTests"/>, etc.).
|
||||
/// </summary>
|
||||
public class AgentWorkflowBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSequential_InvalidArguments_Throws()
|
||||
public void Test_AgentWorkflowBuilder_BuildSequential_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
|
||||
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
public async Task Test_AgentWorkflowBuilder_BuildSequential_DelegatesToBuilderAsync(int numAgents)
|
||||
{
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
|
||||
from i in Enumerable.Range(1, numAgents)
|
||||
select new OrchestrationTestHelpers.DoubleEchoAgent($"agent{i}"));
|
||||
|
||||
// Smoke: end-to-end run produces a non-empty result. Detailed pipeline-ordering
|
||||
// assertions live in SequentialWorkflowBuilderTests.
|
||||
(string updateText, List<ChatMessage>? result, _, _) =
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(numAgents + 1, result.Count);
|
||||
Assert.NotEmpty(updateText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildConcurrent_InvalidArguments_Throws()
|
||||
public void Test_AgentWorkflowBuilder_BuildSequential_WithWorkflowNameSetsNameOnWorkflow()
|
||||
{
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
|
||||
"static-sequential",
|
||||
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"));
|
||||
|
||||
workflow.Name.Should().Be("static-sequential");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_BuildConcurrent_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
|
||||
|
||||
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]));
|
||||
Assert.NotNull(groupChat);
|
||||
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(null!));
|
||||
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants([null!]));
|
||||
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!));
|
||||
|
||||
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
|
||||
{
|
||||
var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]);
|
||||
|
||||
const int DefaultMaxIterations = 40;
|
||||
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
|
||||
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = 0);
|
||||
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = -1);
|
||||
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
|
||||
|
||||
manager.MaximumIterationCount = 30;
|
||||
Assert.Equal(30, manager.MaximumIterationCount);
|
||||
|
||||
manager.MaximumIterationCount = 1;
|
||||
Assert.Equal(1, manager.MaximumIterationCount);
|
||||
|
||||
manager.MaximumIterationCount = int.MaxValue;
|
||||
Assert.Equal(int.MaxValue, manager.MaximumIterationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription()
|
||||
{
|
||||
const string WorkflowName = "Test Group Chat";
|
||||
const string WorkflowDescription = "A test group chat workflow";
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
|
||||
.WithName(WorkflowName)
|
||||
.WithDescription(WorkflowDescription)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
Assert.Equal(WorkflowDescription, workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_WithNameOnly_SetsWorkflowName()
|
||||
{
|
||||
const string WorkflowName = "Named Group Chat";
|
||||
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"))
|
||||
.WithName(WorkflowName)
|
||||
.Build();
|
||||
|
||||
Assert.Equal(WorkflowName, workflow.Name);
|
||||
Assert.Null(workflow.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull()
|
||||
{
|
||||
var workflow = AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"))
|
||||
.Build();
|
||||
|
||||
Assert.Null(workflow.Name);
|
||||
Assert.Null(workflow.Description);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents)
|
||||
{
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential(
|
||||
from i in Enumerable.Range(1, numAgents)
|
||||
select new DoubleEchoAgent($"agent{i}"));
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
const string UserInput = "abc";
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(numAgents + 1, result.Count);
|
||||
|
||||
Assert.Equal(ChatRole.User, result[0].Role);
|
||||
Assert.Null(result[0].AuthorName);
|
||||
Assert.Equal(UserInput, result[0].Text);
|
||||
|
||||
string[] texts = new string[numAgents + 1];
|
||||
texts[0] = UserInput;
|
||||
string expectedTotal = string.Empty;
|
||||
for (int i = 1; i < numAgents + 1; i++)
|
||||
{
|
||||
string id = $"agent{((i - 1) % numAgents) + 1}";
|
||||
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
|
||||
Assert.Equal(ChatRole.Assistant, result[i].Role);
|
||||
Assert.Equal(id, result[i].AuthorName);
|
||||
Assert.Equal(texts[i], result[i].Text);
|
||||
expectedTotal += texts[i];
|
||||
}
|
||||
|
||||
Assert.Equal(expectedTotal, updateText);
|
||||
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
|
||||
|
||||
static string Double(string s) => s + s;
|
||||
}
|
||||
}
|
||||
|
||||
private class DoubleEchoAgent(string name) : AIAgent
|
||||
{
|
||||
public override string Name => name;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new DoubleEchoAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new DoubleEchoAgentSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
var contents = messages.SelectMany(m => m.Contents).ToList();
|
||||
string id = Guid.NewGuid().ToString("N");
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id };
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DoubleEchoAgentSession() : AgentSession();
|
||||
|
||||
[Fact]
|
||||
public async Task BuildConcurrent_AgentsRunInParallelAsync()
|
||||
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_DelegatesToBuilderAsync()
|
||||
{
|
||||
StrongBox<TaskCompletionSource<bool>> barrier = new();
|
||||
StrongBox<int> remaining = new();
|
||||
|
||||
var workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
[
|
||||
new DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
|
||||
new DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
|
||||
]);
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
remaining.Value = 2;
|
||||
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
remaining.Value = 2;
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
Assert.NotEmpty(updateText);
|
||||
Assert.NotNull(result);
|
||||
(string updateText, List<ChatMessage>? result, _, _) =
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
// TODO: https://github.com/microsoft/agent-framework/issues/784
|
||||
// These asserts are flaky until we guarantee message delivery order.
|
||||
Assert.Single(Regex.Matches(updateText, "agent1"));
|
||||
Assert.Single(Regex.Matches(updateText, "agent2"));
|
||||
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
Assert.NotEmpty(updateText);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Single(Regex.Matches(updateText, "agent1"));
|
||||
Assert.Single(Regex.Matches(updateText, "agent2"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_BuildConcurrent_WithWorkflowNameSetsNameOnWorkflow()
|
||||
{
|
||||
const int NumAgents = 3;
|
||||
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
|
||||
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
|
||||
.AddParticipants(new DoubleEchoAgent("agent3"))
|
||||
.Build();
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
"static-concurrent",
|
||||
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")]);
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
const string UserInput = "abc";
|
||||
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(maxIterations + 1, result.Count);
|
||||
|
||||
Assert.Equal(ChatRole.User, result[0].Role);
|
||||
Assert.Null(result[0].AuthorName);
|
||||
Assert.Equal(UserInput, result[0].Text);
|
||||
|
||||
string[] texts = new string[maxIterations + 1];
|
||||
texts[0] = UserInput;
|
||||
string expectedTotal = string.Empty;
|
||||
for (int i = 1; i < maxIterations + 1; i++)
|
||||
{
|
||||
string id = $"agent{((i - 1) % NumAgents) + 1}";
|
||||
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
|
||||
Assert.Equal(ChatRole.Assistant, result[i].Role);
|
||||
Assert.Equal(id, result[i].AuthorName);
|
||||
Assert.Equal(texts[i], result[i].Text);
|
||||
expectedTotal += texts[i];
|
||||
}
|
||||
|
||||
Assert.Equal(expectedTotal, updateText);
|
||||
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
|
||||
|
||||
static string Double(string s) => s + s;
|
||||
}
|
||||
workflow.Name.Should().Be("static-concurrent");
|
||||
}
|
||||
|
||||
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
|
||||
|
||||
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
|
||||
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
|
||||
[Fact]
|
||||
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_AggregatorIsHonoredAsync()
|
||||
{
|
||||
await using StreamingRun run =
|
||||
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
|
||||
: await environment.OpenStreamingAsync(workflow);
|
||||
// Replace the default ("last message from each agent") with a custom aggregator,
|
||||
// and confirm the workflow yields its result.
|
||||
List<ChatMessage> sentinel = [new(ChatRole.Assistant, "custom-aggregator-result")];
|
||||
|
||||
await run.TrySendMessageAsync(input);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
|
||||
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")],
|
||||
aggregator: _ => sentinel);
|
||||
|
||||
return await ProcessWorkflowRunAsync(run);
|
||||
(_, List<ChatMessage>? result, _, _) =
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
|
||||
result.Should().NotBeNull().And.ContainSingle();
|
||||
result![0].Text.Should().Be("custom-aggregator-result");
|
||||
}
|
||||
|
||||
private static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_RejectsNull()
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
WorkflowOutputEvent? output = null;
|
||||
CheckpointInfo? lastCheckpoint = null;
|
||||
|
||||
List<RequestInfoEvent> pendingRequests = [];
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case AgentResponseUpdateEvent responseUpdate:
|
||||
sb.Append(responseUpdate.Data);
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
pendingRequests.Add(requestInfo);
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent e:
|
||||
output = e;
|
||||
break;
|
||||
|
||||
case WorkflowErrorEvent errorEvent:
|
||||
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
|
||||
}
|
||||
|
||||
private static Task<WorkflowRunResult> RunWorkflowAsync(
|
||||
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
|
||||
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
|
||||
|
||||
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_ReturnsConfigurableBuilder()
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Decrement(ref remaining.Value) == 0)
|
||||
{
|
||||
barrier.Value!.SetResult(true);
|
||||
}
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
|
||||
|
||||
await barrier.Value!.Task.ConfigureAwait(false);
|
||||
SequentialWorkflowBuilder builder = AgentWorkflowBuilder.CreateSequentialBuilderWith(agent);
|
||||
Workflow workflow = builder.WithName("via-factory").Build();
|
||||
|
||||
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
workflow.Name.Should().Be("via-factory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_RejectsNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_ReturnsConfigurableBuilder()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
|
||||
|
||||
ConcurrentWorkflowBuilder builder = AgentWorkflowBuilder.CreateConcurrentBuilderWith(agent);
|
||||
Workflow workflow = builder.WithName("via-factory").Build();
|
||||
|
||||
workflow.Name.Should().Be("via-factory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateHandoffBuilderWith_RejectsNull()
|
||||
{
|
||||
#pragma warning disable MAAIW001
|
||||
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!));
|
||||
#pragma warning restore MAAIW001
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateGroupChatBuilderWith_RejectsNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_AgentWorkflowBuilder_CreateMagenticBuilderWith_RejectsNull()
|
||||
{
|
||||
#pragma warning disable MAAIW001
|
||||
Assert.Throws<ArgumentNullException>("managerAgent", () => AgentWorkflowBuilder.CreateMagenticBuilderWith(null!));
|
||||
#pragma warning restore MAAIW001
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests.BackwardsCompatibility;
|
||||
|
||||
/// <summary>
|
||||
/// Tests pinning the JSON shape of checkpoint-adjacent types so older payloads keep
|
||||
/// deserializing correctly after the Outputs overhaul (see implementation-plan §5.7).
|
||||
/// </summary>
|
||||
public class JsonCheckpointSerializationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_options = WorkflowsJsonUtilities.DefaultOptions;
|
||||
|
||||
private static WorkflowInfo BuildInfoWithOutputExecutors(Dictionary<string, HashSet<OutputTag>> outputs)
|
||||
=> new(
|
||||
executors: new Dictionary<string, ExecutorInfo>(),
|
||||
edges: new Dictionary<string, List<EdgeInfo>>(),
|
||||
requestPorts: [],
|
||||
startExecutorId: "start",
|
||||
outputExecutorIds: outputs);
|
||||
|
||||
// ---------- WorkflowOutputEvent.Tags in-process round-trip (no JSON) ----------
|
||||
|
||||
[Fact]
|
||||
public void Test_WorkflowOutputEvent_SingleTagCtorPopulatesTags()
|
||||
{
|
||||
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1", tag: OutputTag.Intermediate);
|
||||
|
||||
evt.ExecutorId.Should().Be("e1");
|
||||
evt.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
evt.HasTag(OutputTag.Intermediate).Should().BeTrue();
|
||||
evt.IsIntermediate().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WorkflowOutputEvent_NoTagsCtorIsUntagged()
|
||||
{
|
||||
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1");
|
||||
|
||||
evt.Tags.Should().BeEmpty();
|
||||
evt.IsIntermediate().Should().BeFalse("an event with no tags is a terminal/regular output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_WorkflowOutputEvent_MultiTagCtorPreservesAllTags()
|
||||
{
|
||||
OutputTag customTag = JsonSerializer.Deserialize<OutputTag>("\"custom\"", s_options);
|
||||
|
||||
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1", tags: new[] { OutputTag.Intermediate, customTag });
|
||||
|
||||
evt.Tags.Should().HaveCount(2);
|
||||
evt.HasTag(OutputTag.Intermediate).Should().BeTrue();
|
||||
evt.HasTag(customTag).Should().BeTrue();
|
||||
evt.IsIntermediate().Should().BeTrue();
|
||||
}
|
||||
|
||||
// ---------- WorkflowInfo.OutputExecutorIds shape ----------
|
||||
//
|
||||
// Note: per the comment in WorkflowsJsonUtilities, WorkflowEvent / WorkflowOutputEvent
|
||||
// is *not* currently a serialized checkpoint shape (events are not persisted into
|
||||
// checkpoints today), so we do not pin a JSON round-trip for Tags on the event itself
|
||||
// here. The tag JSON round-trip is exercised by OutputTagTests; the
|
||||
// OutputExecutorIds map shape is the actually-load-bearing back-compat surface.
|
||||
|
||||
[Fact]
|
||||
public void Test_JsonCheckpoint_WorkflowOutputExecutorsReadsLegacyArrayShape()
|
||||
{
|
||||
const string LegacyJson = """
|
||||
{
|
||||
"executors": {},
|
||||
"edges": {},
|
||||
"requestPorts": [],
|
||||
"startExecutorId": "start",
|
||||
"outputExecutorIds": ["a", "b"]
|
||||
}
|
||||
""";
|
||||
|
||||
WorkflowInfo? info = JsonSerializer.Deserialize<WorkflowInfo>(LegacyJson, s_options);
|
||||
|
||||
info.Should().NotBeNull();
|
||||
info!.OutputExecutorIds.Should().HaveCount(2);
|
||||
info.OutputExecutorIds["a"].Should().BeEmpty("legacy ids are untagged regular outputs");
|
||||
info.OutputExecutorIds["b"].Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_JsonCheckpoint_WorkflowOutputExecutorsWritesMapShape()
|
||||
{
|
||||
Dictionary<string, HashSet<OutputTag>> outputs = new()
|
||||
{
|
||||
["a"] = [],
|
||||
["b"] = [OutputTag.Intermediate],
|
||||
};
|
||||
|
||||
WorkflowInfo info = BuildInfoWithOutputExecutors(outputs);
|
||||
|
||||
string json = JsonSerializer.Serialize(info, s_options);
|
||||
|
||||
WorkflowInfo? back = JsonSerializer.Deserialize<WorkflowInfo>(json, s_options);
|
||||
|
||||
back.Should().NotBeNull();
|
||||
back!.OutputExecutorIds.Should().HaveCount(2);
|
||||
back.OutputExecutorIds["a"].Should().BeEmpty();
|
||||
back.OutputExecutorIds["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
|
||||
|
||||
// The map shape is detectable in the serialized JSON: the property value starts with `{`, not `[`.
|
||||
int idx = json.IndexOf("\"outputExecutorIds\"", System.StringComparison.Ordinal);
|
||||
idx.Should().BeGreaterThan(-1);
|
||||
int colon = json.IndexOf(':', idx);
|
||||
int firstNonSpace = colon + 1;
|
||||
while (firstNonSpace < json.Length && char.IsWhiteSpace(json[firstNonSpace]))
|
||||
{
|
||||
firstNonSpace++;
|
||||
}
|
||||
json[firstNonSpace].Should().Be('{', "OutputExecutorIds is written in the new map shape");
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.UnitTests.Futures;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
|
||||
#pragma warning disable RCS1186 // Use Regex instance instead of static method
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class ConcurrentWorkflowBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test_ConcurrentWorkflowBuilder_InvalidArguments_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>("agents", () => new ConcurrentWorkflowBuilder(null!));
|
||||
Assert.Throws<ArgumentException>("agents", () => new ConcurrentWorkflowBuilder().Build());
|
||||
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
|
||||
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_ConcurrentWorkflowBuilder_AgentsRunInParallelAsync()
|
||||
{
|
||||
StrongBox<TaskCompletionSource<bool>> barrier = new();
|
||||
StrongBox<int> remaining = new();
|
||||
|
||||
var workflow = new ConcurrentWorkflowBuilder(
|
||||
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining))
|
||||
.Build();
|
||||
|
||||
for (int iter = 0; iter < 3; iter++)
|
||||
{
|
||||
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
remaining.Value = 2;
|
||||
|
||||
(string updateText, List<ChatMessage>? result, _, _) =
|
||||
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
|
||||
Assert.NotEmpty(updateText);
|
||||
Assert.NotNull(result);
|
||||
|
||||
// TODO: https://github.com/microsoft/agent-framework/issues/784
|
||||
// These asserts are flaky until we guarantee message delivery order.
|
||||
Assert.Single(Regex.Matches(updateText, "agent1"));
|
||||
Assert.Single(Regex.Matches(updateText, "agent2"));
|
||||
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ConcurrentWorkflowBuilder_DefaultDesignationsMatchSpec()
|
||||
{
|
||||
Workflow workflow = new ConcurrentWorkflowBuilder(
|
||||
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgent("agent2"),
|
||||
new OrchestrationTestHelpers.DoubleEchoAgent("agent3"))
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
designations.Where(kvp => kvp.Value.Count == 0)
|
||||
.Should().ContainSingle("ConcurrentEndExecutor is the sole terminal output by default");
|
||||
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
|
||||
.Should().HaveCount(6, "every agent (3) and per-agent accumulator (3) is designated intermediate by default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ConcurrentWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
|
||||
|
||||
Workflow workflow = new ConcurrentWorkflowBuilder(a1, a2, a3)
|
||||
.WithOutputFrom(a1)
|
||||
.WithIntermediateOutputFrom([a2])
|
||||
.Build();
|
||||
|
||||
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
|
||||
|
||||
designations.Should().HaveCount(2,
|
||||
"only the two explicitly-designated agents land on the inner builder; the end + accumulator defaults are suppressed");
|
||||
designations.Values.Where(tags => tags.Count == 0)
|
||||
.Should().ContainSingle("agent1 is the only terminal designation");
|
||||
designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate))
|
||||
.Should().ContainSingle("agent2 is the only intermediate designation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ConcurrentWorkflowBuilder_DesignationForNonParticipantThrows()
|
||||
{
|
||||
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
|
||||
|
||||
ConcurrentWorkflowBuilder builder = new ConcurrentWorkflowBuilder(participant)
|
||||
.WithIntermediateOutputFrom([stranger]);
|
||||
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ConcurrentWorkflowBuilder_WithNamePropagatesToWorkflow()
|
||||
{
|
||||
Workflow workflow = new ConcurrentWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
|
||||
.WithName("named-concurrent")
|
||||
.Build();
|
||||
|
||||
workflow.Name.Should().Be("named-concurrent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ConcurrentWorkflowBuilder_WithDescriptionPropagatesToWorkflow()
|
||||
{
|
||||
Workflow workflow = new ConcurrentWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
|
||||
.WithDescription("describes the concurrent fan-out/fan-in")
|
||||
.Build();
|
||||
|
||||
workflow.Description.Should().Be("describes the concurrent fan-out/fan-in");
|
||||
}
|
||||
|
||||
[Collection(FuturesSerialCollection.Name)]
|
||||
public class AsAgentForwarding
|
||||
{
|
||||
[Fact]
|
||||
public async Task Test_ConcurrentWorkflowBuilder_AsAgent_OnlyTerminalDesignationSurfacesAsync()
|
||||
{
|
||||
using FuturesScope _ = new(enabled: true);
|
||||
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent1 = new("agent1");
|
||||
OrchestrationTestHelpers.DoubleEchoAgent agent2 = new("agent2");
|
||||
|
||||
// Designate only agent1 as a terminal output source — agent2 and the fan-in
|
||||
// aggregator default-intermediate designations are suppressed.
|
||||
Workflow workflow = new ConcurrentWorkflowBuilder(agent1, agent2)
|
||||
.WithOutputFrom(agent1)
|
||||
.Build();
|
||||
|
||||
List<AgentResponseUpdate> updates = await workflow
|
||||
.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "abc"))
|
||||
.ToListAsync();
|
||||
|
||||
HashSet<string> authoredBy = updates
|
||||
.Select(u => u.AuthorName)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Select(n => n!)
|
||||
.ToHashSet();
|
||||
|
||||
authoredBy.Should().Contain("agent1", "the designated agent must surface");
|
||||
authoredBy.Should().NotContain("agent2",
|
||||
"the undesignated agent must not surface when only one is designated under Futures-on");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user