mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01fc518b29 | ||
|
|
f3c3efed43 | ||
|
|
bbccb7c28c | ||
|
|
dbc312a78a | ||
|
|
bb9ed63a34 | ||
|
|
6b94315161 | ||
|
|
bc0e65d716 | ||
|
|
4268080c20 | ||
|
|
fe08574a7c | ||
|
|
f970a699d8 | ||
|
|
f29bae8fbc | ||
|
|
c3901a4ddd | ||
|
|
ba617fc3b5 | ||
|
|
afa7834e2e | ||
|
|
c6951c21f6 | ||
|
|
a982428916 | ||
|
|
90a3e5de47 | ||
|
|
49a6e433a3 | ||
|
|
6086a74302 | ||
|
|
fa8cfb7567 | ||
|
|
6de4c24fdd | ||
|
|
a5f355e04a | ||
|
|
0cf48923cd | ||
|
|
cdc4809b8a | ||
|
|
043208241a | ||
|
|
05ebb966cf | ||
|
|
c83a944e85 | ||
|
|
5d98beddf5 | ||
|
|
e0d0ad16a0 | ||
|
|
f36096ce1a | ||
|
|
03e14ca187 | ||
|
|
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 |
@@ -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,181 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
function getPullRequest(context) {
|
||||||
|
const pullRequest = context.payload.pull_request;
|
||||||
|
if (!pullRequest?.number || !pullRequest.user?.login) {
|
||||||
|
throw new Error('This script must be run from a pull_request_target event.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
author: pullRequest.user.login,
|
||||||
|
authorType: pullRequest.user.type,
|
||||||
|
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
|
||||||
|
number: pullRequest.number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLabel({ github, owner, repo, labelName }) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
name: labelName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
name: labelName,
|
||||||
|
color: 'd93f0b',
|
||||||
|
description: 'Community author has exceeded the open pull request limit.',
|
||||||
|
});
|
||||||
|
} catch (createError) {
|
||||||
|
if (createError.status !== 422) {
|
||||||
|
throw createError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLabel(labels, labelName) {
|
||||||
|
if (!labelName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDependabotAuthor({ author, authorType }) {
|
||||||
|
return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
|
||||||
|
return [
|
||||||
|
`Thank you for your contribution, @${author}.`,
|
||||||
|
'',
|
||||||
|
`To keep the review queue manageable, we currently limit community contributors to ${maxOpenPrs} `
|
||||||
|
+ `open pull requests at a time. This PR would put you at ${openPrCount} open pull requests, `
|
||||||
|
+ 'so we are closing it automatically.',
|
||||||
|
'',
|
||||||
|
'Please focus on getting your existing PRs reviewed, merged, or closed before opening another one. '
|
||||||
|
+ `If a maintainer asked you to open this PR, they can apply the \`${exemptLabelName}\` label and reopen it.`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }) {
|
||||||
|
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorOpenPullRequestNumbers = openPullRequests
|
||||||
|
.filter((pullRequest) => pullRequest.user?.login === author)
|
||||||
|
.map((pullRequest) => pullRequest.number);
|
||||||
|
const currentPrIsOpen = authorOpenPullRequestNumbers.includes(pullRequestNumber);
|
||||||
|
const existingOpenPrCount = currentPrIsOpen
|
||||||
|
? authorOpenPullRequestNumbers.length - 1
|
||||||
|
: authorOpenPullRequestNumbers.length;
|
||||||
|
|
||||||
|
return existingOpenPrCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const { author, authorType, labels, number } = getPullRequest(context);
|
||||||
|
|
||||||
|
if (isDependabotAuthor({ author, authorType })) {
|
||||||
|
core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`);
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
closed: false,
|
||||||
|
dependabotExempt: true,
|
||||||
|
openPrCount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLabel(labels, exemptLabelName)) {
|
||||||
|
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
closed: false,
|
||||||
|
exempt: true,
|
||||||
|
openPrCount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPrCount = await getOpenPrCount({
|
||||||
|
github,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
author,
|
||||||
|
pullRequestNumber: number,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (openPrCount <= maxOpenPrs) {
|
||||||
|
core.info(
|
||||||
|
`${author} has ${openPrCount} open pull request(s), which is within the limit of ${maxOpenPrs}.`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
closed: false,
|
||||||
|
openPrCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureLabel({
|
||||||
|
github,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
labelName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: number,
|
||||||
|
labels: [labelName],
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: number,
|
||||||
|
body: buildLimitMessage({
|
||||||
|
author,
|
||||||
|
exemptLabelName,
|
||||||
|
maxOpenPrs,
|
||||||
|
openPrCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: number,
|
||||||
|
state: 'closed',
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(
|
||||||
|
`${author} has ${openPrCount} open pull request(s), which exceeds the limit of ${maxOpenPrs}. `
|
||||||
|
+ `Closed PR #${number}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
author,
|
||||||
|
closed: true,
|
||||||
|
openPrCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildLimitMessage,
|
||||||
|
enforcePrLimit,
|
||||||
|
getOpenPrCount,
|
||||||
|
};
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for pr_limit_moderation.js.
|
||||||
|
*
|
||||||
|
* Run with: node --test .github/tests/test_pr_limit_moderation.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
|
||||||
|
return {
|
||||||
|
repo: {
|
||||||
|
owner: 'microsoft',
|
||||||
|
repo: 'agent-framework',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
pull_request: {
|
||||||
|
number,
|
||||||
|
labels: labels.map((name) => ({ name })),
|
||||||
|
user: {
|
||||||
|
login: author,
|
||||||
|
type: authorType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCore() {
|
||||||
|
const messages = [];
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
info(message) {
|
||||||
|
messages.push(message);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGithub({
|
||||||
|
itemNumbers,
|
||||||
|
labelExists = true,
|
||||||
|
pullRequests = createPullRequestPage({ numbers: itemNumbers }),
|
||||||
|
}) {
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async paginate(method, params) {
|
||||||
|
calls.push({ api: 'paginate', method, params });
|
||||||
|
return pullRequests;
|
||||||
|
},
|
||||||
|
rest: {
|
||||||
|
issues: {
|
||||||
|
async getLabel(params) {
|
||||||
|
calls.push({ api: 'issues.getLabel', params });
|
||||||
|
if (!labelExists) {
|
||||||
|
const error = new Error('Not Found');
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { data: { name: params.name } };
|
||||||
|
},
|
||||||
|
async createLabel(params) {
|
||||||
|
calls.push({ api: 'issues.createLabel', params });
|
||||||
|
return { data: { name: params.name } };
|
||||||
|
},
|
||||||
|
async addLabels(params) {
|
||||||
|
calls.push({ api: 'issues.addLabels', params });
|
||||||
|
return { data: [] };
|
||||||
|
},
|
||||||
|
async createComment(params) {
|
||||||
|
calls.push({ api: 'issues.createComment', params });
|
||||||
|
return { data: { id: 1 } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pulls: {
|
||||||
|
async list(params) {
|
||||||
|
calls.push({ api: 'pulls.list', params });
|
||||||
|
return { data: pullRequests };
|
||||||
|
},
|
||||||
|
async update(params) {
|
||||||
|
calls.push({ api: 'pulls.update', params });
|
||||||
|
return { data: { state: params.state } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPullRequestPage({ author = 'community-user', numbers }) {
|
||||||
|
return numbers.map((number) => ({
|
||||||
|
number,
|
||||||
|
user: {
|
||||||
|
login: author,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PR limit enforcement
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('PR limit enforcement', () => {
|
||||||
|
it('does not close the PR when the author is at the open PR limit', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 123],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext(),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, false);
|
||||||
|
assert.equal(result.openPrCount, 10);
|
||||||
|
assert.deepEqual(
|
||||||
|
github.calls.map((call) => call.api),
|
||||||
|
['paginate'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the new PR when the pull list includes it', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [123, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext(),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, true);
|
||||||
|
assert.equal(result.openPrCount, 11);
|
||||||
|
assert.deepEqual(
|
||||||
|
github.calls.map((call) => call.api),
|
||||||
|
[
|
||||||
|
'paginate',
|
||||||
|
'issues.getLabel',
|
||||||
|
'issues.addLabels',
|
||||||
|
'issues.createComment',
|
||||||
|
'pulls.update',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the current PR on top of existing open PRs', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [123, ...Array.from({ length: 24 }, (_, index) => index + 1)],
|
||||||
|
pullRequests: createPullRequestPage({
|
||||||
|
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext(),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, true);
|
||||||
|
assert.equal(result.openPrCount, 26);
|
||||||
|
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
|
||||||
|
assert.match(comment, /This PR would put you at 26 open pull requests/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the label when it does not already exist', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||||
|
labelExists: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext(),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
github.calls.map((call) => call.api),
|
||||||
|
[
|
||||||
|
'paginate',
|
||||||
|
'issues.getLabel',
|
||||||
|
'issues.createLabel',
|
||||||
|
'issues.addLabels',
|
||||||
|
'issues.createComment',
|
||||||
|
'pulls.update',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
github.calls.find((call) => call.api === 'issues.createLabel').params.name,
|
||||||
|
'too-many-prs',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a 422 race when creating the label', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||||
|
labelExists: false,
|
||||||
|
});
|
||||||
|
github.rest.issues.createLabel = async (params) => {
|
||||||
|
github.calls.push({ api: 'issues.createLabel', params });
|
||||||
|
const error = new Error('Validation Failed');
|
||||||
|
error.status = 422;
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext(),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
github.calls.map((call) => call.api),
|
||||||
|
[
|
||||||
|
'paginate',
|
||||||
|
'issues.getLabel',
|
||||||
|
'issues.createLabel',
|
||||||
|
'issues.addLabels',
|
||||||
|
'issues.createComment',
|
||||||
|
'pulls.update',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a diplomatic close message with the configured limit', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||||
|
pullRequests: createPullRequestPage({
|
||||||
|
author: 'octo-contributor',
|
||||||
|
numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext({ author: 'octo-contributor' }),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = github.calls.find((call) => call.api === 'issues.createComment').params.body;
|
||||||
|
assert.match(comment, /Thank you for your contribution/);
|
||||||
|
assert.match(comment, /limit community contributors to 10 open pull requests/);
|
||||||
|
assert.match(comment, /@octo-contributor/);
|
||||||
|
assert.match(comment, /`pr-limit-exempt` label and reopen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close an exempt PR when it is reopened', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 123],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext({ labels: ['PR-LIMIT-EXEMPT'] }),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, false);
|
||||||
|
assert.equal(result.exempt, true);
|
||||||
|
assert.equal(result.openPrCount, null);
|
||||||
|
assert.deepEqual(github.calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close Dependabot PRs', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||||
|
pullRequests: createPullRequestPage({
|
||||||
|
author: 'dependabot[bot]',
|
||||||
|
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, false);
|
||||||
|
assert.equal(result.dependabotExempt, true);
|
||||||
|
assert.equal(result.openPrCount, null);
|
||||||
|
assert.deepEqual(github.calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the current PR when the author has more than one page of open PRs', async () => {
|
||||||
|
const github = createGithub({
|
||||||
|
itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await enforcePrLimit({
|
||||||
|
github,
|
||||||
|
context: createContext({ number: 123 }),
|
||||||
|
core: createCore(),
|
||||||
|
exemptLabelName: 'pr-limit-exempt',
|
||||||
|
maxOpenPrs: 10,
|
||||||
|
labelName: 'too-many-prs',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.closed, true);
|
||||||
|
assert.equal(result.openPrCount, 101);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,6 +121,9 @@ jobs:
|
|||||||
python
|
python
|
||||||
declarative-agents
|
declarative-agents
|
||||||
|
|
||||||
|
- name: Free runner disk space
|
||||||
|
uses: ./.github/actions/free-runner-disk-space
|
||||||
|
|
||||||
- name: Setup dotnet
|
- name: Setup dotnet
|
||||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||||
with:
|
with:
|
||||||
@@ -191,6 +194,9 @@ jobs:
|
|||||||
python
|
python
|
||||||
declarative-agents
|
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)
|
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
|
||||||
- name: Start Azure Cosmos DB Emulator
|
- name: Start Azure Cosmos DB Emulator
|
||||||
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||||
@@ -365,6 +371,9 @@ jobs:
|
|||||||
dotnet
|
dotnet
|
||||||
python
|
python
|
||||||
|
|
||||||
|
- name: Free runner disk space
|
||||||
|
uses: ./.github/actions/free-runner-disk-space
|
||||||
|
|
||||||
- name: Setup dotnet
|
- name: Setup dotnet
|
||||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||||
with:
|
with:
|
||||||
@@ -452,6 +461,9 @@ jobs:
|
|||||||
python
|
python
|
||||||
declarative-agents
|
declarative-agents
|
||||||
|
|
||||||
|
- name: Free runner disk space
|
||||||
|
uses: ./.github/actions/free-runner-disk-space
|
||||||
|
|
||||||
- name: Setup dotnet
|
- name: Setup dotnet
|
||||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||||
with:
|
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:
|
with:
|
||||||
persist-credentials: false
|
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
|
# Checks the status of hyperlinks in all files
|
||||||
- name: Run linkspector
|
- name: Run linkspector
|
||||||
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
|
uses: umbrelladocs/action-linkspector@963b6264d7de32c904942a70b488d3407453049e # v1
|
||||||
|
|||||||
@@ -474,6 +474,45 @@ jobs:
|
|||||||
path: ./python/pytest.xml
|
path: ./python/pytest.xml
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# GitHub Copilot integration tests
|
||||||
|
python-tests-github-copilot:
|
||||||
|
name: Python Integration Tests - GitHub Copilot
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: integration
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||||
|
GITHUB_COPILOT_TIMEOUT: "120"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: python
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.checkout-ref }}
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up python and install the project
|
||||||
|
id: python-setup
|
||||||
|
uses: ./.github/actions/python-setup
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.UV_PYTHON }}
|
||||||
|
os: ${{ runner.os }}
|
||||||
|
- name: Test with pytest (GitHub Copilot integration)
|
||||||
|
run: >
|
||||||
|
uv run pytest --import-mode=importlib
|
||||||
|
packages/github_copilot/tests
|
||||||
|
-m integration
|
||||||
|
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||||
|
--retries 2 --retry-delay 5
|
||||||
|
--junitxml=pytest.xml
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: test-results-github-copilot
|
||||||
|
path: ./python/pytest.xml
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||||
python-integration-test-report:
|
python-integration-test-report:
|
||||||
name: Integration Test Report
|
name: Integration Test Report
|
||||||
@@ -490,6 +529,7 @@ jobs:
|
|||||||
python-tests-foundry,
|
python-tests-foundry,
|
||||||
python-tests-foundry-hosting,
|
python-tests-foundry-hosting,
|
||||||
python-tests-cosmos,
|
python-tests-cosmos,
|
||||||
|
python-tests-github-copilot,
|
||||||
]
|
]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -553,7 +593,8 @@ jobs:
|
|||||||
python-tests-functions,
|
python-tests-functions,
|
||||||
python-tests-foundry,
|
python-tests-foundry,
|
||||||
python-tests-foundry-hosting,
|
python-tests-foundry-hosting,
|
||||||
python-tests-cosmos
|
python-tests-cosmos,
|
||||||
|
python-tests-github-copilot
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- name: Fail workflow if tests failed
|
- name: Fail workflow if tests failed
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ jobs:
|
|||||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||||
|
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||||
@@ -85,6 +86,8 @@ jobs:
|
|||||||
- 'python/packages/foundry_hosting/**'
|
- 'python/packages/foundry_hosting/**'
|
||||||
cosmos:
|
cosmos:
|
||||||
- 'python/packages/azure-cosmos/**'
|
- 'python/packages/azure-cosmos/**'
|
||||||
|
github_copilot:
|
||||||
|
- 'python/packages/github_copilot/**'
|
||||||
# run only if 'python' files were changed
|
# run only if 'python' files were changed
|
||||||
- name: python tests
|
- name: python tests
|
||||||
if: steps.filter.outputs.python == 'true'
|
if: steps.filter.outputs.python == 'true'
|
||||||
@@ -658,6 +661,58 @@ jobs:
|
|||||||
path: ./python/pytest.xml
|
path: ./python/pytest.xml
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# GitHub Copilot integration tests
|
||||||
|
python-tests-github-copilot:
|
||||||
|
name: Python Tests - GitHub Copilot Integration
|
||||||
|
needs: paths-filter
|
||||||
|
if: >
|
||||||
|
github.event_name != 'pull_request' &&
|
||||||
|
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||||
|
(github.event_name != 'merge_group' ||
|
||||||
|
needs.paths-filter.outputs.githubCopilotChanged == 'true' ||
|
||||||
|
needs.paths-filter.outputs.coreChanged == 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: integration
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||||
|
GITHUB_COPILOT_TIMEOUT: "120"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: python
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
- name: Set up python and install the project
|
||||||
|
id: python-setup
|
||||||
|
uses: ./.github/actions/python-setup
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.UV_PYTHON }}
|
||||||
|
os: ${{ runner.os }}
|
||||||
|
- name: Test with pytest (GitHub Copilot integration)
|
||||||
|
run: >
|
||||||
|
uv run pytest --import-mode=importlib
|
||||||
|
packages/github_copilot/tests
|
||||||
|
-m integration
|
||||||
|
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||||
|
--retries 2 --retry-delay 5
|
||||||
|
--junitxml=pytest.xml
|
||||||
|
- name: Surface failing tests
|
||||||
|
if: always()
|
||||||
|
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||||
|
with:
|
||||||
|
path: ./python/pytest.xml
|
||||||
|
summary: true
|
||||||
|
display-options: fEX
|
||||||
|
fail-on-empty: false
|
||||||
|
title: GitHub Copilot integration test results
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: test-results-github-copilot
|
||||||
|
path: ./python/pytest.xml
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||||
python-integration-test-report:
|
python-integration-test-report:
|
||||||
name: Integration Test Report
|
name: Integration Test Report
|
||||||
@@ -674,6 +729,7 @@ jobs:
|
|||||||
python-tests-foundry,
|
python-tests-foundry,
|
||||||
python-tests-foundry-hosting,
|
python-tests-foundry-hosting,
|
||||||
python-tests-cosmos,
|
python-tests-cosmos,
|
||||||
|
python-tests-github-copilot,
|
||||||
]
|
]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -735,6 +791,7 @@ jobs:
|
|||||||
python-tests-foundry,
|
python-tests-foundry,
|
||||||
python-tests-foundry-hosting,
|
python-tests-foundry-hosting,
|
||||||
python-tests-cosmos,
|
python-tests-cosmos,
|
||||||
|
python-tests-github-copilot,
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- name: Fail workflow if tests failed
|
- name: Fail workflow if tests failed
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -23,7 +24,7 @@ jobs:
|
|||||||
- name: Download coverage report
|
- name: Download coverage report
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
github-token: ${{ github.token }}
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
path: ./python
|
path: ./python
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
@@ -38,9 +39,9 @@ jobs:
|
|||||||
echo "PR number file 'pr_number' is missing or empty"
|
echo "PR number file 'pr_number' is missing or empty"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
PR_NUMBER=$(head -1 pr_number | tr -dc '0-9')
|
PR_NUMBER=$(cat pr_number)
|
||||||
if [ -z "$PR_NUMBER" ]; then
|
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||||
echo "PR number file 'pr_number' does not contain a valid PR number"
|
echo "::error::PR number file contains invalid content"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||||
@@ -48,7 +49,7 @@ jobs:
|
|||||||
id: coverageComment
|
id: coverageComment
|
||||||
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
|
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
github-token: ${{ github.token }}
|
||||||
issue-number: ${{ env.PR_NUMBER }}
|
issue-number: ${{ env.PR_NUMBER }}
|
||||||
pytest-xml-coverage-path: python/python-coverage.xml
|
pytest-xml-coverage-path: python/python-coverage.xml
|
||||||
title: "Python Test Coverage Report"
|
title: "Python Test Coverage Report"
|
||||||
|
|||||||
@@ -248,3 +248,4 @@ dotnet/filtered-*.slnx
|
|||||||
.omx/
|
.omx/
|
||||||
|
|
||||||
**/issues/
|
**/issues/
|
||||||
|
.test_*
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||||
<!-- Azure.* -->
|
<!-- Azure.* -->
|
||||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.25" />
|
||||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.4" />
|
||||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" 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.Search.Documents" Version="12.0.0" />
|
||||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.2" />
|
<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.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
<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="Azure.Identity" Version="1.21.0" />
|
||||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
<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.AsyncInterfaces" Version="10.0.6" />
|
||||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
<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.CodeDom" Version="10.0.0" />
|
||||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||||
<!-- MCP -->
|
<!-- MCP -->
|
||||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<!-- Hyperlight -->
|
<!-- Hyperlight -->
|
||||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||||
|
|||||||
@@ -174,6 +174,7 @@
|
|||||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
<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_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_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
|
||||||
|
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step26_FoundryToolboxMcpSkills/Agent_Step26_FoundryToolboxMcpSkills.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||||
@@ -343,6 +344,9 @@
|
|||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
|
||||||
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
|
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
|
||||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
|
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
@@ -604,6 +608,7 @@
|
|||||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
<Project Path="src/Microsoft.Agents.AI.Hosting.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.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.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.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" />
|
<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.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.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.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.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.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.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.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.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.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.Purview\\Microsoft.Agents.AI.Purview.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.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.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.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Central version prefix - applies to all nuget packages. -->
|
<!-- Central version prefix - applies to all nuget packages. -->
|
||||||
<VersionPrefix>1.7.0</VersionPrefix>
|
<VersionPrefix>1.9.0</VersionPrefix>
|
||||||
<RCNumber>1</RCNumber>
|
<RCNumber>1</RCNumber>
|
||||||
<DateSuffix>260526</DateSuffix>
|
<DateSuffix>260603</DateSuffix>
|
||||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
<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)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||||
<GitTag>1.7.0</GitTag>
|
<GitTag>1.9.0</GitTag>
|
||||||
|
|
||||||
<Configurations>Debug;Release;Publish</Configurations>
|
<Configurations>Debug;Release;Publish</Configurations>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddHttpClient().AddLogging();
|
builder.Services.AddHttpClient().AddLogging();
|
||||||
builder.Services.AddAGUI();
|
builder.Services.AddAGUI();
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
|||||||
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
|
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
|
||||||
builder.Services.AddAGUI();
|
builder.Services.AddAGUI();
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddHttpClient().AddLogging();
|
builder.Services.AddHttpClient().AddLogging();
|
||||||
builder.Services.AddAGUI();
|
builder.Services.AddAGUI();
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
|||||||
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
|
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
|
||||||
builder.Services.AddAGUI();
|
builder.Services.AddAGUI();
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseHttpLogging();
|
app.UseHttpLogging();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ builder.Services.AddAGUI();
|
|||||||
// Configure to listen on port 8888
|
// Configure to listen on port 8888
|
||||||
builder.WebHost.UseUrls("http://localhost:8888");
|
builder.WebHost.UseUrls("http://localhost:8888");
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
+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 |
|
| [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 |
|
| [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 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
|
## Running the samples
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,95 @@ public static class AnsiEscapes
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static string ResetAttributes => "\x1b[0m";
|
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
|
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||||
{
|
{
|
||||||
ConsoleColor.Black => 30,
|
ConsoleColor.Black => 30,
|
||||||
|
|||||||
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
|
|||||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="items">The items to measure.</param>
|
/// <param name="items">The items to measure.</param>
|
||||||
/// <returns>The total number of lines all items will occupy.</returns>
|
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
/// <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;
|
int total = 0;
|
||||||
for (int i = 0; i < items.Count; i++)
|
for (int i = 0; i < items.Count; i++)
|
||||||
{
|
{
|
||||||
total += CountLines(items[i]);
|
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
return total;
|
return total;
|
||||||
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
|||||||
{
|
{
|
||||||
string text = props.Items[i];
|
string text = props.Items[i];
|
||||||
string[] lines = text.Split('\n');
|
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(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
|
||||||
Console.Write(lines[j]);
|
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]);
|
Console.Write(props.Items[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the offset from bottom for the start of the new last item
|
// Calculate the offset from bottom for the start of the new last item,
|
||||||
int lastItemLines = CountLines(props.Items[^1]);
|
// 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;
|
this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0;
|
||||||
|
|
||||||
// Update rendered count
|
// Update rendered count
|
||||||
this._renderedCount = props.Items.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>
|
/// <summary>
|
||||||
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
|
/// 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
|
/// 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 TProps : ConsoleReactiveProps
|
||||||
where TState : ConsoleReactiveState
|
where TState : ConsoleReactiveState
|
||||||
{
|
{
|
||||||
private readonly object _renderLock = new();
|
|
||||||
private TProps? _lastRenderedProps;
|
private TProps? _lastRenderedProps;
|
||||||
private TState? _lastRenderedState;
|
private TState? _lastRenderedState;
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public override void Render()
|
public override void Render()
|
||||||
{
|
{
|
||||||
lock (this._renderLock)
|
lock (RenderLock)
|
||||||
{
|
{
|
||||||
if (this.Props is null)
|
if (this.Props is null)
|
||||||
{
|
{
|
||||||
@@ -97,7 +101,7 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override void Invalidate()
|
public override void Invalidate()
|
||||||
{
|
{
|
||||||
lock (this._renderLock)
|
lock (RenderLock)
|
||||||
{
|
{
|
||||||
this._lastRenderedProps = default;
|
this._lastRenderedProps = default;
|
||||||
this._lastRenderedState = default;
|
this._lastRenderedState = default;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
|||||||
private int _scrollRegionBottom;
|
private int _scrollRegionBottom;
|
||||||
private bool _resizedSinceLastRender = true;
|
private bool _resizedSinceLastRender = true;
|
||||||
private bool _deactivated;
|
private bool _deactivated;
|
||||||
|
private BottomPanelMode _lastRenderedBottomPanelMode;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||||
@@ -341,7 +342,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate queued items panel height
|
// 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
|
// Build the bottom panel child based on mode
|
||||||
ConsoleReactiveComponent bottomChild;
|
ConsoleReactiveComponent bottomChild;
|
||||||
@@ -406,6 +407,14 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
|
|||||||
bottomChild = this._textInput;
|
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
|
var ruleProps = new TopBottomRuleProps
|
||||||
{
|
{
|
||||||
Width = state.ConsoleWidth,
|
Width = state.ConsoleWidth,
|
||||||
|
|||||||
+7
-5
@@ -88,16 +88,17 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
|||||||
{
|
{
|
||||||
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(collectedText);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
// JSON parsing failed — fall back to rendering as regular text output.
|
||||||
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
await ux.WriteTextAsync(collectedText).ConfigureAwait(false);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (planningResponse is 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +119,8 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
|||||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+88
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
using Harness.Shared.Console.Observers;
|
using Harness.Shared.Console.Observers;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
@@ -53,9 +54,94 @@ public sealed class OpenAIResponsesErrorObserver : ConsoleObserver
|
|||||||
|
|
||||||
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
case StreamingResponseIncompleteUpdate incompleteUpdate:
|
||||||
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString();
|
||||||
string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}";
|
if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase))
|
||||||
await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow);
|
{
|
||||||
|
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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
[SendsMessage(typeof(List<ChatMessage>))]
|
||||||
|
[SendsMessage(typeof(TurnToken))]
|
||||||
|
private sealed partial class ConcurrentStartExecutor()
|
||||||
|
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
|
||||||
{
|
{
|
||||||
[MessageHandler]
|
[MessageHandler]
|
||||||
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
|
||||||
|
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessageHandler]
|
[MessageHandler]
|
||||||
@@ -63,13 +67,16 @@ internal static partial class WorkflowHelper
|
|||||||
{
|
{
|
||||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ValueTask ResetAsync() => default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executor that aggregates the results from the concurrent agents.
|
/// Executor that aggregates the results from the concurrent agents.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[YieldsOutput(typeof(List<ChatMessage>))]
|
[YieldsOutput(typeof(string))]
|
||||||
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
private sealed partial class ConcurrentAggregationExecutor() :
|
||||||
|
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
|
||||||
{
|
{
|
||||||
private readonly List<ChatMessage> _messages = [];
|
private readonly List<ChatMessage> _messages = [];
|
||||||
|
|
||||||
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
|
|||||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ValueTask ResetAsync()
|
||||||
|
{
|
||||||
|
this._messages.Clear();
|
||||||
|
return default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>",
|
|
||||||
"REDIS_CONNECTION_STRING": "localhost:6379",
|
|
||||||
"REDIS_STREAM_TTL_MINUTES": "10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"IsEncrypted": false,
|
|
||||||
"Values": {
|
|
||||||
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
|
|
||||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
||||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
|
||||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
|
||||||
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.AI.Projects" />
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
<PackageReference Include="Azure.Identity" />
|
<PackageReference Include="Azure.Identity" />
|
||||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" />
|
||||||
<PackageReference Include="DotNetEnv" />
|
<PackageReference Include="DotNetEnv" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||||
|
ASPNETCORE_URLS=http://+:8088
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
|
||||||
|
FOUNDRY_TOOLBOX_NAME=<your-toolbox-name>
|
||||||
|
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Dockerfile for end-users consuming the Agent Framework via NuGet packages.
|
||||||
|
#
|
||||||
|
# This Dockerfile performs a full `dotnet restore` and `dotnet publish` inside the container,
|
||||||
|
# which only succeeds when the project references its dependencies via PackageReference (see the
|
||||||
|
# commented-out section in HostedToolboxMcpSkills.csproj). Contributors building from the
|
||||||
|
# agent-framework repository source must use Dockerfile.contributor instead because
|
||||||
|
# ProjectReference dependencies live outside this folder and cannot be restored from inside
|
||||||
|
# this build context.
|
||||||
|
#
|
||||||
|
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet restore
|
||||||
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
EXPOSE 8088
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8088
|
||||||
|
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Dockerfile for contributors building from the agent-framework repository source.
|
||||||
|
#
|
||||||
|
# This project uses ProjectReference to the local source, which means a standard
|
||||||
|
# multi-stage Docker build cannot resolve dependencies outside this folder.
|
||||||
|
# Pre-publish the app targeting the container runtime and copy the output:
|
||||||
|
#
|
||||||
|
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||||
|
# docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
|
||||||
|
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-toolbox-mcp-skills -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-toolbox-mcp-skills
|
||||||
|
#
|
||||||
|
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||||
|
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY out/ .
|
||||||
|
EXPOSE 8088
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8088
|
||||||
|
ENTRYPOINT ["dotnet", "HostedToolboxMcpSkills.dll"]
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||||
|
<RootNamespace>HostedToolboxMcpSkills</RootNamespace>
|
||||||
|
<AssemblyName>HostedToolboxMcpSkills</AssemblyName>
|
||||||
|
<NoWarn>$(NoWarn);</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||||
|
<PackageReference Include="DotNetEnv" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||||
|
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Mcp" Version="1.6.1-preview.260514.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</Project>
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// Hosted Toolbox MCP Skills Agent
|
||||||
|
//
|
||||||
|
// Demonstrates how to host an agent that discovers MCP-based skills from a
|
||||||
|
// Foundry Toolbox MCP endpoint and injects them as AIContextProviders using
|
||||||
|
// AgentSkillsProviderBuilder.UseMcpSkills().
|
||||||
|
//
|
||||||
|
// Required environment variables:
|
||||||
|
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||||
|
// FOUNDRY_TOOLBOX_NAME - Name of the Foundry Toolbox to connect to
|
||||||
|
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-5)
|
||||||
|
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Azure.AI.Projects;
|
||||||
|
using Azure.Core;
|
||||||
|
using Azure.Identity;
|
||||||
|
using DotNetEnv;
|
||||||
|
using Hosted_Shared_Contributor_Setup;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
|
||||||
|
// Load .env file if present (for local development)
|
||||||
|
Env.TraversePath().Load();
|
||||||
|
|
||||||
|
var projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||||
|
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5";
|
||||||
|
var toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME")
|
||||||
|
?? throw new InvalidOperationException("FOUNDRY_TOOLBOX_NAME is not set.");
|
||||||
|
|
||||||
|
// Build the Toolbox MCP URL from the project endpoint and toolbox name.
|
||||||
|
var toolboxMcpServerUrl = $"{projectEndpoint.TrimEnd('/')}/toolboxes/{toolboxName}/mcp?api-version=v1";
|
||||||
|
|
||||||
|
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||||
|
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||||
|
TokenCredential credential = new ChainedTokenCredential(
|
||||||
|
new DevTemporaryTokenCredential(),
|
||||||
|
new DefaultAzureCredential());
|
||||||
|
|
||||||
|
// ── Connect to the Foundry Toolbox MCP endpoint ─────────────────────────────
|
||||||
|
// Create an HttpClient that attaches a fresh Foundry bearer token to every request.
|
||||||
|
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default") { CheckCertificateRevocationList = true });
|
||||||
|
|
||||||
|
Console.WriteLine($"Connecting to Foundry Toolbox '{toolboxName}' MCP server...");
|
||||||
|
|
||||||
|
await using var mcpClient = await McpClient.CreateAsync(
|
||||||
|
new HttpClientTransport(
|
||||||
|
new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||||
|
Name = toolboxName,
|
||||||
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
|
AdditionalHeaders = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
httpClient));
|
||||||
|
|
||||||
|
// ── Configure MCP-based skills provider ──────────────────────────────────────
|
||||||
|
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||||
|
.UseMcpSkills(mcpClient)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// ── Create the agent ─────────────────────────────────────────────────────────
|
||||||
|
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
|
||||||
|
.AsAIAgent(new ChatClientAgentOptions
|
||||||
|
{
|
||||||
|
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-mcp-skills",
|
||||||
|
Description = "Hosted agent with MCP skills discovered from a Foundry Toolbox",
|
||||||
|
ChatOptions = new()
|
||||||
|
{
|
||||||
|
ModelId = deployment,
|
||||||
|
Instructions = "You are a helpful assistant.",
|
||||||
|
},
|
||||||
|
AIContextProviders = [skillsProvider],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Build the host ───────────────────────────────────────────────────────────
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Services.AddFoundryResponses(agent);
|
||||||
|
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.MapFoundryResponses();
|
||||||
|
|
||||||
|
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||||
|
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||||
|
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||||
|
app.MapDevTemporaryLocalAgentEndpoint();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HttpClientHandler: attaches a fresh Foundry bearer token to every request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : HttpClientHandler
|
||||||
|
{
|
||||||
|
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||||
|
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Hosted-ToolboxMcpSkills
|
||||||
|
|
||||||
|
A hosted agent that discovers **MCP-based skills from a Foundry Toolbox** and makes them available to the agent using `AgentSkillsProviderBuilder.UseMcpSkills(mcpClient)`.
|
||||||
|
|
||||||
|
The `AgentSkillsProvider` is attached to the agent as a context provider and implements the [Agent Skills](https://agentskills.io/) progressive-disclosure pattern. When the agent is prompted, it discovers available skills in the Foundry Toolbox via the provider:
|
||||||
|
|
||||||
|
1. **Advertise** - skill names and descriptions are injected into the system prompt so the agent knows what is available.
|
||||||
|
2. **Load** - when the agent decides a skill is relevant, it retrieves the full skill body with detailed instructions via the provider.
|
||||||
|
3. **Read resources** - if a skill includes supplementary content (reference documents, assets), the agent reads them on demand via the provider.
|
||||||
|
|
||||||
|
This way the full skill body and resources are only loaded when the agent actually needs them, reducing token usage.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||||
|
- An Azure AI Foundry project with a deployed model (e.g., `gpt-5`)
|
||||||
|
- A Foundry Toolbox already configured with skills provisioned
|
||||||
|
- Azure CLI logged in (`az login`)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy the template and fill in your values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set your Azure AI Foundry project endpoint and toolbox name:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||||
|
ASPNETCORE_URLS=http://+:8088
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5
|
||||||
|
FOUNDRY_TOOLBOX_NAME=my-toolbox
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||||
|
|
||||||
|
## Running directly (contributors)
|
||||||
|
|
||||||
|
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent will start on `http://localhost:8088`.
|
||||||
|
|
||||||
|
### Test it
|
||||||
|
|
||||||
|
Using the Azure Developer CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
azd ai agent invoke --local "What skills do you have available?"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
|
||||||
|
|
||||||
|
### 1. Publish for the container runtime (Linux Alpine)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the Docker image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.contributor -t hosted-toolbox-mcp-skills .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the container
|
||||||
|
|
||||||
|
Generate a bearer token on your host and pass it to the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate token (expires in ~1 hour)
|
||||||
|
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||||
|
|
||||||
|
# Run with token
|
||||||
|
docker run --rm -p 8088:8088 \
|
||||||
|
-e AGENT_NAME=hosted-toolbox-mcp-skills \
|
||||||
|
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||||
|
--env-file .env \
|
||||||
|
hosted-toolbox-mcp-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
|
||||||
|
|
||||||
|
### 4. Test it
|
||||||
|
|
||||||
|
Using the Azure Developer CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
azd ai agent invoke --local "What skills do you have available?"
|
||||||
|
```
|
||||||
|
|
||||||
|
## NuGet package users
|
||||||
|
|
||||||
|
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||||
|
name: hosted-toolbox-mcp-skills
|
||||||
|
displayName: "Hosted Toolbox MCP Skills Agent"
|
||||||
|
|
||||||
|
description: >
|
||||||
|
A hosted agent that discovers MCP-based skills from a Foundry Toolbox
|
||||||
|
and makes them available to the agent via the agent skills provider.
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
tags:
|
||||||
|
- AI Agent Hosting
|
||||||
|
- Azure AI AgentServer
|
||||||
|
- Responses Protocol
|
||||||
|
- Agent Framework
|
||||||
|
- MCP
|
||||||
|
- Model Context Protocol
|
||||||
|
- Agent Skills
|
||||||
|
- Foundry Toolbox
|
||||||
|
- Foundry Toolbox Skills
|
||||||
|
|
||||||
|
template:
|
||||||
|
name: hosted-toolbox-mcp-skills
|
||||||
|
kind: hosted
|
||||||
|
protocols:
|
||||||
|
- protocol: responses
|
||||||
|
version: 1.0.0
|
||||||
|
resources:
|
||||||
|
cpu: "0.25"
|
||||||
|
memory: 0.5Gi
|
||||||
|
environment_variables:
|
||||||
|
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||||
|
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||||
|
- name: FOUNDRY_TOOLBOX_NAME
|
||||||
|
value: "{{FOUNDRY_TOOLBOX_NAME}}"
|
||||||
|
parameters:
|
||||||
|
properties:
|
||||||
|
- name: FOUNDRY_TOOLBOX_NAME
|
||||||
|
secret: false
|
||||||
|
description: Name of the Foundry Toolbox to connect to for MCP skill discovery
|
||||||
|
resources:
|
||||||
|
- kind: model
|
||||||
|
id: gpt-5
|
||||||
|
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||||
|
kind: hosted
|
||||||
|
name: hosted-toolbox-mcp-skills
|
||||||
|
protocols:
|
||||||
|
- protocol: responses
|
||||||
|
version: 1.0.0
|
||||||
|
resources:
|
||||||
|
cpu: "0.25"
|
||||||
|
memory: 0.5Gi
|
||||||
|
environment_variables:
|
||||||
|
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||||
|
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||||
|
- name: FOUNDRY_TOOLBOX_NAME
|
||||||
|
value: ${FOUNDRY_TOOLBOX_NAME}
|
||||||
+2
@@ -13,8 +13,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.AI.Projects" />
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Core" />
|
||||||
<PackageReference Include="Azure.Identity" />
|
<PackageReference Include="Azure.Identity" />
|
||||||
<PackageReference Include="DotNetEnv" />
|
<PackageReference Include="DotNetEnv" />
|
||||||
|
<PackageReference Include="System.ClientModel" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+2
@@ -13,8 +13,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.AI.Projects" />
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Core" />
|
||||||
<PackageReference Include="Azure.Identity" />
|
<PackageReference Include="Azure.Identity" />
|
||||||
<PackageReference Include="DotNetEnv" />
|
<PackageReference Include="DotNetEnv" />
|
||||||
|
<PackageReference Include="System.ClientModel" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ else
|
|||||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
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);
|
builder.AddA2AServer(hostA2AAgent);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ builder.Services.AddHttpClient().AddLogging();
|
|||||||
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
|
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
|
||||||
builder.Services.AddAGUI();
|
builder.Services.AddAGUI();
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseHttpLogging();
|
app.UseHttpLogging();
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ var agent = new AzureOpenAIClient(
|
|||||||
AGUIServerSerializerContext.Default.Options)
|
AGUIServerSerializerContext.Default.Options)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
// Register the agent with the host and configure it to use an in-memory session store
|
// 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.
|
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
|
||||||
builder
|
builder
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddHttpClient().AddLogging();
|
builder.Services.AddHttpClient().AddLogging();
|
||||||
builder.Services.AddAGUI();
|
builder.Services.AddAGUI();
|
||||||
|
|
||||||
|
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||||
|
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||||
|
// deployments, e.g.:
|
||||||
|
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ builder.AddDevUI();
|
|||||||
builder.AddOpenAIChatCompletions();
|
builder.AddOpenAIChatCompletions();
|
||||||
builder.AddOpenAIResponses();
|
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(
|
var pirateAgentBuilder = builder.AddAIAgent(
|
||||||
"pirate",
|
"pirate",
|
||||||
instructions: "You are a pirate. Speak like a 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();
|
pirateAgentBuilder.AddA2AServer();
|
||||||
knightsKnavesAgentBuilder.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();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|||||||
Executable → Regular
@@ -297,7 +297,7 @@ public class AgentResponse
|
|||||||
AgentId = this.AgentId,
|
AgentId = this.AgentId,
|
||||||
ResponseId = this.ResponseId,
|
ResponseId = this.ResponseId,
|
||||||
MessageId = message.MessageId,
|
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 outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
|
||||||
|
|
||||||
var itemId = GenerateItemId("fc");
|
// Use the SDK's convenience method so the OutputItemFunctionToolCallOutput
|
||||||
var outputItem = new 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,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.AI.Projects" />
|
<PackageReference Include="Azure.AI.Projects" />
|
||||||
|
<PackageReference Include="Azure.Core" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
|
||||||
<PackageReference Include="OpenAI" />
|
<PackageReference Include="OpenAI" />
|
||||||
|
<PackageReference Include="System.ClientModel" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Shared.DiagnosticIds;
|
using Microsoft.Shared.DiagnosticIds;
|
||||||
|
|
||||||
namespace Microsoft.Extensions.AI;
|
namespace Microsoft.Extensions.AI;
|
||||||
@@ -32,11 +34,19 @@ public static class ChatClientHarnessExtensions
|
|||||||
/// additional context providers, and chat history provider.
|
/// additional context providers, and chat history provider.
|
||||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="loggerFactory">
|
||||||
|
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="services">
|
||||||
|
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
|
||||||
|
/// </param>
|
||||||
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
||||||
public static HarnessAgent AsHarnessAgent(
|
public static HarnessAgent AsHarnessAgent(
|
||||||
this IChatClient chatClient,
|
this IChatClient chatClient,
|
||||||
int maxContextWindowTokens,
|
int maxContextWindowTokens,
|
||||||
int maxOutputTokens,
|
int maxOutputTokens,
|
||||||
HarnessAgentOptions? options = null) =>
|
HarnessAgentOptions? options = null,
|
||||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
ILoggerFactory? loggerFactory = null,
|
||||||
|
IServiceProvider? services = null) =>
|
||||||
|
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Compaction;
|
|||||||
using Microsoft.Agents.AI.Tools.Shell;
|
using Microsoft.Agents.AI.Tools.Shell;
|
||||||
#endif
|
#endif
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Shared.DiagnosticIds;
|
using Microsoft.Shared.DiagnosticIds;
|
||||||
using Microsoft.Shared.Diagnostics;
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
|
||||||
@@ -105,6 +106,12 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
/// additional context providers, and chat history provider.
|
/// additional context providers, and chat history provider.
|
||||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="loggerFactory">
|
||||||
|
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="services">
|
||||||
|
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
|
||||||
|
/// </param>
|
||||||
/// <exception cref="ArgumentNullException">
|
/// <exception cref="ArgumentNullException">
|
||||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -112,18 +119,20 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
|
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||||
: base(BuildAgent(
|
: base(BuildAgent(
|
||||||
Throw.IfNull(chatClient),
|
Throw.IfNull(chatClient),
|
||||||
maxContextWindowTokens,
|
maxContextWindowTokens,
|
||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
options))
|
options,
|
||||||
|
loggerFactory,
|
||||||
|
services))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||||
{
|
{
|
||||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||||
|
|
||||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||||
|
|
||||||
@@ -137,10 +146,10 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
|
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||||
{
|
{
|
||||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||||
maxContextWindowTokens: maxContextWindowTokens,
|
maxContextWindowTokens: maxContextWindowTokens,
|
||||||
@@ -165,13 +174,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
|
|
||||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
||||||
|
|
||||||
var compactionProvider = new CompactionProvider(compactionStrategy);
|
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
|
||||||
|
|
||||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
|
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
|
||||||
|
|
||||||
return chatClient
|
return chatClient
|
||||||
.AsBuilder()
|
.AsBuilder()
|
||||||
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
|
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||||
: null)
|
: null)
|
||||||
.UseMessageInjection()
|
.UseMessageInjection()
|
||||||
@@ -189,7 +198,9 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
RequirePerServiceCallChatHistoryPersistence = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
WarnOnChatHistoryProviderConflict = false,
|
WarnOnChatHistoryProviderConflict = false,
|
||||||
ThrowOnChatHistoryProviderConflict = false,
|
ThrowOnChatHistoryProviderConflict = false,
|
||||||
});
|
},
|
||||||
|
loggerFactory,
|
||||||
|
services);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
||||||
@@ -215,7 +226,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
|
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options, ILoggerFactory? loggerFactory)
|
||||||
{
|
{
|
||||||
var providers = new List<AIContextProvider>();
|
var providers = new List<AIContextProvider>();
|
||||||
|
|
||||||
@@ -255,8 +266,8 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
|||||||
if (options?.DisableAgentSkillsProvider is not true)
|
if (options?.DisableAgentSkillsProvider is not true)
|
||||||
{
|
{
|
||||||
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
|
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
|
||||||
? new AgentSkillsProvider(source)
|
? new AgentSkillsProvider(source, loggerFactory: loggerFactory)
|
||||||
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
|
: new AgentSkillsProvider(Directory.GetCurrentDirectory(), loggerFactory: loggerFactory);
|
||||||
|
|
||||||
providers.Add(skillsProvider);
|
providers.Add(skillsProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions
|
|||||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
/// <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)
|
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
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="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>
|
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
/// <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)
|
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
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="agent">The agent instance to create an A2A server for.</param>
|
||||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
/// <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)
|
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
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="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>
|
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
/// <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)
|
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(services);
|
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="agent">The agent instance to create an A2A server for.</param>
|
||||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
/// <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)
|
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(services);
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
@@ -140,9 +185,17 @@ public static class A2AServerServiceCollectionExtensions
|
|||||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
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(
|
var hostAgent = new AIHostAgent(
|
||||||
innerAgent: agent,
|
innerAgent: agent,
|
||||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
sessionStore: agentSessionStore);
|
||||||
|
|
||||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -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
|
/// 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).
|
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public static IEndpointConventionBuilder MapAGUI(
|
public static IEndpointConventionBuilder MapAGUI(
|
||||||
this IEndpointRouteBuilder endpoints,
|
this IEndpointRouteBuilder endpoints,
|
||||||
@@ -83,7 +103,16 @@ public static class AGUIEndpointRouteBuilderExtensions
|
|||||||
ArgumentNullException.ThrowIfNull(aiAgent);
|
ArgumentNullException.ThrowIfNull(aiAgent);
|
||||||
|
|
||||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
|
|
||||||
|
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||||
|
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
|
||||||
|
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||||
|
{
|
||||||
|
agentSessionStore ??= new NoopAgentSessionStore();
|
||||||
|
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore);
|
||||||
|
|
||||||
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
|
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
|||||||
+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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Hosting;
|
namespace Microsoft.Agents.AI.Hosting;
|
||||||
|
|
||||||
@@ -9,9 +11,39 @@ namespace Microsoft.Agents.AI.Hosting;
|
|||||||
/// Defines the contract for storing and retrieving agent conversation threads.
|
/// Defines the contract for storing and retrieving agent conversation threads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Implementations of this interface enable persistent storage of conversation threads,
|
/// Implementations of this interface enable persistent storage of conversation threads,
|
||||||
/// allowing conversations to be resumed across HTTP requests, application restarts,
|
/// allowing conversations to be resumed across HTTP requests, application restarts,
|
||||||
/// or different service instances in hosted scenarios.
|
/// 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>
|
/// </remarks>
|
||||||
public abstract class AgentSessionStore
|
public abstract class AgentSessionStore
|
||||||
{
|
{
|
||||||
@@ -43,4 +75,35 @@ public abstract class AgentSessionStore
|
|||||||
AIAgent agent,
|
AIAgent agent,
|
||||||
string conversationId,
|
string conversationId,
|
||||||
CancellationToken cancellationToken = default);
|
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 System;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.Shared.Diagnostics;
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Hosting;
|
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.
|
/// Configures the host agent builder to use an in-memory session store for agent session management.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="builder">The host agent builder to configure with the in-memory session store.</param>
|
/// <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>
|
/// <returns>The same <paramref name="builder"/> instance, configured to use an in-memory session store.</returns>
|
||||||
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder)
|
public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true)
|
||||||
{
|
=> builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation);
|
||||||
builder.ServiceCollection.AddKeyedSingleton<AgentSessionStore>(builder.Name, new InMemoryAgentSessionStore());
|
|
||||||
return builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the specified agent session store with the host agent builder, enabling session-specific storage for
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="builder">The host agent builder to configure with the session store. Cannot be null.</param>
|
/// <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="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>
|
/// <returns>The same host agent builder instance, allowing for method chaining.</returns>
|
||||||
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store)
|
public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true)
|
||||||
{
|
=> builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation);
|
||||||
builder.ServiceCollection.AddKeyedSingleton(builder.Name, store);
|
|
||||||
return builder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures the host agent builder to use a custom session store implementation for agent sessions.
|
/// 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>
|
/// name.</param>
|
||||||
/// <param name="lifetime">The DI service lifetime for the session store registration. Defaults to <see cref="ServiceLifetime.Singleton"/>
|
/// <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>
|
/// 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>
|
/// <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) =>
|
builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) =>
|
||||||
{
|
{
|
||||||
Throw.IfNull(key);
|
Throw.IfNull(key);
|
||||||
var keyString = key as string;
|
var keyString = key as string;
|
||||||
Throw.IfNullOrEmpty(keyString);
|
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}'.");
|
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);
|
}, lifetime);
|
||||||
return builder;
|
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
|
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
|
||||||
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
/// such as Redis, SQL Server, or Azure Cosmos DB.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
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);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
<!-- Preview while Microsoft.Agents.AI.Foundry is preview (blocked by Azure.AI.Projects 2.1.0-beta). Flip to IsReleased=true once that ships stable. -->
|
||||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
<IsReleased>true</IsReleased>
|
||||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
|
|
||||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||||
|
|
||||||
<!-- Package not yet published to NuGet — disable baseline validation until first release -->
|
<!-- First Stable release after the RC milestone. Baseline against the latest
|
||||||
|
published RC so package validation catches accidental breaking changes.
|
||||||
|
Future releases should bump this to the previous stable version. -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<EnablePackageValidation>false</EnablePackageValidation>
|
<PackageValidationBaselineVersion>1.8.0-rc1</PackageValidationBaselineVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
+8
-1
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
<IsReleased>true</IsReleased>
|
||||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -13,6 +13,13 @@
|
|||||||
|
|
||||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||||
|
|
||||||
|
<!-- First Stable release after the RC milestone. Baseline against the latest
|
||||||
|
published RC so package validation catches accidental breaking changes.
|
||||||
|
Future releases should bump this to the previous stable version. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<PackageValidationBaselineVersion>1.8.0-rc1</PackageValidationBaselineVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- NuGet Package Settings -->
|
<!-- NuGet Package Settings -->
|
||||||
<Title>Microsoft Agent Framework Declarative Workflows</Title>
|
<Title>Microsoft Agent Framework Declarative Workflows</Title>
|
||||||
|
|||||||
+66
-13
@@ -27,6 +27,14 @@ internal sealed class InvokeMcpToolExecutor(
|
|||||||
WorkflowFormulaState state) :
|
WorkflowFormulaState state) :
|
||||||
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
||||||
{
|
{
|
||||||
|
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of evaluated parameters at approval-request time.
|
||||||
|
/// Used to prevent TOCTOU attacks where state mutates during the approval window.
|
||||||
|
/// </summary>
|
||||||
|
private ApprovalSnapshot? _approvalSnapshot;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Step identifiers for the MCP tool invocation workflow.
|
/// Step identifiers for the MCP tool invocation workflow.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -75,18 +83,18 @@ internal sealed class InvokeMcpToolExecutor(
|
|||||||
|
|
||||||
if (requireApproval)
|
if (requireApproval)
|
||||||
{
|
{
|
||||||
// Create tool call content for approval request
|
// Snapshot the evaluated parameters to prevent TOCTOU attacks.
|
||||||
|
// If state mutates during the approval window, the approved values are used on resume.
|
||||||
|
this._approvalSnapshot = new ApprovalSnapshot(serverUrl, serverLabel, toolName, arguments, connectionName);
|
||||||
|
|
||||||
|
// Create tool call content for approval request.
|
||||||
|
// Transport headers (e.g. Authorization) are intentionally excluded from the
|
||||||
|
// approval event: they must not cross into the externally-surfaced approval request.
|
||||||
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
|
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
|
||||||
{
|
{
|
||||||
Arguments = arguments
|
Arguments = arguments
|
||||||
};
|
};
|
||||||
|
|
||||||
if (headers != null)
|
|
||||||
{
|
|
||||||
toolCall.AdditionalProperties ??= [];
|
|
||||||
toolCall.AdditionalProperties.Add(headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
|
ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
|
||||||
|
|
||||||
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
|
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
|
||||||
@@ -141,13 +149,14 @@ internal sealed class InvokeMcpToolExecutor(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approved - now invoke the tool
|
// Approved - use the snapshot from approval-request time to prevent TOCTOU attacks.
|
||||||
string serverUrl = this.GetServerUrl();
|
// Headers are re-evaluated (they may contain auth secrets that should not be persisted).
|
||||||
string? serverLabel = this.GetServerLabel();
|
string serverUrl = this._approvalSnapshot?.ServerUrl ?? this.GetServerUrl();
|
||||||
string toolName = this.GetToolName();
|
string? serverLabel = this._approvalSnapshot?.ServerLabel ?? this.GetServerLabel();
|
||||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
string toolName = this._approvalSnapshot?.ToolName ?? this.GetToolName();
|
||||||
|
Dictionary<string, object?>? arguments = this._approvalSnapshot?.Arguments ?? this.GetArguments();
|
||||||
Dictionary<string, string>? headers = this.GetHeaders();
|
Dictionary<string, string>? headers = this.GetHeaders();
|
||||||
string? connectionName = this.GetConnectionName();
|
string? connectionName = this._approvalSnapshot?.ConnectionName ?? this.GetConnectionName();
|
||||||
|
|
||||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||||
serverUrl,
|
serverUrl,
|
||||||
@@ -166,9 +175,33 @@ internal sealed class InvokeMcpToolExecutor(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Clear the approval snapshot after successful completion.
|
||||||
|
this._approvalSnapshot = null;
|
||||||
|
await ClearSnapshotStateAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
|
||||||
|
/// </remarks>
|
||||||
|
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Restores the approval snapshot from workflow state after a checkpoint restore.
|
||||||
|
/// </remarks>
|
||||||
|
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
bool autoSend = this.GetAutoSendValue();
|
bool autoSend = this.GetAutoSendValue();
|
||||||
@@ -369,4 +402,24 @@ internal sealed class InvokeMcpToolExecutor(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the persisted approval snapshot state after a successful tool invocation.
|
||||||
|
/// </summary>
|
||||||
|
private static async ValueTask ClearSnapshotStateAsync(IWorkflowContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the evaluated parameters at approval-request time so that
|
||||||
|
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
|
||||||
|
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ApprovalSnapshot(
|
||||||
|
string ServerUrl,
|
||||||
|
string? ServerLabel,
|
||||||
|
string ToolName,
|
||||||
|
Dictionary<string, object?>? Arguments,
|
||||||
|
string? ConnectionName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
using Microsoft.Shared.Diagnostics;
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows;
|
namespace Microsoft.Agents.AI.Workflows;
|
||||||
@@ -19,6 +20,28 @@ public sealed class AgentResponseEvent : WorkflowOutputEvent
|
|||||||
this.Response = Throw.IfNull(response);
|
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>
|
/// <summary>
|
||||||
/// Gets the agent response.
|
/// Gets the agent response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
|
|||||||
this.Update = Throw.IfNull(update);
|
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>
|
/// <summary>
|
||||||
/// Gets the agent run response update.
|
/// Gets the agent run response update.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
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.Extensions.AI;
|
||||||
using Microsoft.Shared.Diagnostics;
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
|
||||||
@@ -37,31 +33,10 @@ public static partial class AgentWorkflowBuilder
|
|||||||
{
|
{
|
||||||
Throw.IfNullOrEmpty(agents);
|
Throw.IfNullOrEmpty(agents);
|
||||||
|
|
||||||
// Create a builder that chains the agents together in sequence. The workflow simply begins
|
SequentialWorkflowBuilder builder = new(agents);
|
||||||
// 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);
|
|
||||||
if (workflowName is not null)
|
if (workflowName is not null)
|
||||||
{
|
{
|
||||||
builder = builder.WithName(workflowName);
|
builder.WithName(workflowName);
|
||||||
}
|
}
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
}
|
}
|
||||||
@@ -107,41 +82,14 @@ public static partial class AgentWorkflowBuilder
|
|||||||
{
|
{
|
||||||
Throw.IfNull(agents);
|
Throw.IfNull(agents);
|
||||||
|
|
||||||
// A workflow needs a starting executor, so we create one that forwards everything to each agent.
|
ConcurrentWorkflowBuilder builder = new(agents);
|
||||||
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);
|
|
||||||
if (workflowName is not null)
|
if (workflowName is not null)
|
||||||
{
|
{
|
||||||
builder = builder.WithName(workflowName);
|
builder.WithName(workflowName);
|
||||||
|
}
|
||||||
|
if (aggregator is not null)
|
||||||
|
{
|
||||||
|
builder.WithAggregator(aggregator);
|
||||||
}
|
}
|
||||||
return builder.Build();
|
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
|
/// 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.
|
/// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
|
||||||
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
|
public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent)
|
||||||
{
|
{
|
||||||
Throw.IfNull(initialAgent);
|
Throw.IfNull(initialAgent);
|
||||||
@@ -179,4 +126,31 @@ public static partial class AgentWorkflowBuilder
|
|||||||
Throw.IfNull(managerFactory);
|
Throw.IfNull(managerFactory);
|
||||||
return new GroupChatWorkflowBuilder(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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
@@ -15,14 +16,14 @@ internal sealed class WorkflowInfo
|
|||||||
Dictionary<string, List<EdgeInfo>> edges,
|
Dictionary<string, List<EdgeInfo>> edges,
|
||||||
HashSet<RequestPortInfo> requestPorts,
|
HashSet<RequestPortInfo> requestPorts,
|
||||||
string startExecutorId,
|
string startExecutorId,
|
||||||
HashSet<string>? outputExecutorIds)
|
Dictionary<string, HashSet<OutputTag>>? outputExecutorIds)
|
||||||
{
|
{
|
||||||
this.Executors = Throw.IfNull(executors);
|
this.Executors = Throw.IfNull(executors);
|
||||||
this.Edges = Throw.IfNull(edges);
|
this.Edges = Throw.IfNull(edges);
|
||||||
this.RequestPorts = Throw.IfNull(requestPorts);
|
this.RequestPorts = Throw.IfNull(requestPorts);
|
||||||
|
|
||||||
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
|
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
|
||||||
this.OutputExecutorIds = outputExecutorIds ?? [];
|
this.OutputExecutorIds = outputExecutorIds ?? new Dictionary<string, HashSet<OutputTag>>(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Dictionary<string, ExecutorInfo> Executors { get; }
|
public Dictionary<string, ExecutorInfo> Executors { get; }
|
||||||
@@ -32,7 +33,15 @@ internal sealed class WorkflowInfo
|
|||||||
public TypeId? InputType { get; }
|
public TypeId? InputType { get; }
|
||||||
public string StartExecutorId { 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)
|
public bool IsMatch(Workflow workflow)
|
||||||
{
|
{
|
||||||
@@ -80,9 +89,12 @@ internal sealed class WorkflowInfo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the outputs
|
// Validate the outputs (key set + tag set per id must match)
|
||||||
if (workflow.OutputExecutors.Count != this.OutputExecutorIds.Count ||
|
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;
|
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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
namespace Microsoft.Agents.AI.Workflows.Execution;
|
||||||
|
|
||||||
internal sealed class OutputFilter(Workflow workflow)
|
internal sealed class OutputFilter(Workflow workflow)
|
||||||
{
|
{
|
||||||
public bool CanOutput(string sourceExecutorId, object output)
|
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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
@@ -13,6 +15,16 @@ namespace Microsoft.Agents.AI.Workflows;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class GroupChatManager
|
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>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
|
/// Initializes a new instance of the <see cref="GroupChatManager"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,12 +60,22 @@ public abstract class GroupChatManager
|
|||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filters the chat history before it's passed to the next agent.
|
/// Filters the messages broadcast to participants for the current turn.
|
||||||
/// </summary>
|
/// </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.
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
/// 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(
|
protected internal virtual ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
|
||||||
IReadOnlyList<ChatMessage> history,
|
IReadOnlyList<ChatMessage> history,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
@@ -78,4 +100,125 @@ public abstract class GroupChatManager
|
|||||||
{
|
{
|
||||||
this.IterationCount = 0;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user