Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d34deeb82 | |||
| a2927c1c09 | |||
| c68c099347 | |||
| 61802723ff | |||
| ddb0622f9c | |||
| 12b23250f4 | |||
| bfc73a5b14 | |||
| 1f1da1bddb | |||
| 83ba938d1e | |||
| d97c901301 | |||
| d1d2610b28 | |||
| 848443ac68 | |||
| 1466d68cf1 | |||
| d08200d00e | |||
| fb38b1d10a | |||
| a70fe21298 | |||
| f6a3c43e9a | |||
| e6f7b3e9be | |||
| a1f3e536bc | |||
| c033adb1f4 | |||
| 09473fa7ed | |||
| a4f02aabf0 | |||
| afdf8af400 | |||
| 9e836f7b42 | |||
| 9cf5143321 | |||
| b6b16ddb75 | |||
| c218067646 | |||
| ac474100ce | |||
| a057cd505c | |||
| c66bb39ea2 | |||
| 7c6b1e975f | |||
| 0d2925037d | |||
| b5e635ed4d | |||
| 1036fa7438 | |||
| 62da382082 | |||
| 3604ba70f6 | |||
| 3ab2630243 | |||
| bc59c72170 | |||
| d5f2c77b35 | |||
| 6afae2f9b4 | |||
| cad81923e3 | |||
| 5ab8877ba5 | |||
| f4e49958f3 | |||
| 5282c158aa | |||
| dde7635760 | |||
| 85c00fc55b | |||
| f19a129b55 | |||
| a376577263 | |||
| b2549337ff | |||
| 05834b56e3 | |||
| 42ae534a07 | |||
| a17102f9f5 | |||
| b3f2e53923 | |||
| b5300fe0c0 | |||
| c35a63ed8d | |||
| 47cd0a508d | |||
| 56e9a8f74c | |||
| ba0ad2d1d2 | |||
| b123480b65 | |||
| 4c0d9ed43c | |||
| 1c0082721c | |||
| 6c0950adeb | |||
| f1ba16e3fd | |||
| cba77e3cd0 | |||
| 7ca8bb55b6 | |||
| d93fc2dd74 | |||
| 54617557e6 | |||
| df198005fd | |||
| 0ceca9a76a | |||
| 23977a6045 | |||
| 18b03ea487 | |||
| 56c4425db2 | |||
| 13066cdf96 | |||
| f11cfd9d76 | |||
| 774fc94bd2 | |||
| 4bac2c2c05 | |||
| 43568f1ef2 | |||
| 52005ff17d | |||
| a4e4a5a51c | |||
| c8fb491644 | |||
| e57f046d8a | |||
| c9b19e831f | |||
| beb65b21a8 | |||
| b3d523ee50 | |||
| 6f38cb724d | |||
| 8e74360d52 | |||
| 7f4cc296fd | |||
| f3057ef20c | |||
| 68136ee081 | |||
| 875031ff56 | |||
| 87af313119 | |||
| 9ac548ad15 | |||
| 737042fc93 | |||
| e677ccc3b1 | |||
| d9c0c36379 | |||
| 32a547a1a7 |
@@ -0,0 +1,112 @@
|
||||
name: Get GitHub automation token
|
||||
description: Creates a GitHub App installation token with a temporary PAT fallback
|
||||
|
||||
inputs:
|
||||
mode:
|
||||
description: Authentication mode (app, app-with-fallback, or pat)
|
||||
required: false
|
||||
default: app-with-fallback
|
||||
azure-client-id:
|
||||
description: Client ID of the Azure workload identity
|
||||
required: false
|
||||
azure-tenant-id:
|
||||
description: Azure tenant ID
|
||||
required: false
|
||||
azure-subscription-id:
|
||||
description: Azure subscription containing the Key Vault
|
||||
required: false
|
||||
key-vault-name:
|
||||
description: Azure Key Vault name
|
||||
required: false
|
||||
key-name:
|
||||
description: Key Vault key used to sign the GitHub App JWT
|
||||
required: false
|
||||
github-app-client-id:
|
||||
description: GitHub App client ID
|
||||
required: false
|
||||
github-app-installation-id:
|
||||
description: GitHub App installation ID
|
||||
required: false
|
||||
repository:
|
||||
description: Repository to include in the installation token
|
||||
required: false
|
||||
fallback-token:
|
||||
description: PAT used temporarily when app authentication is unavailable
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: GitHub App installation token or fallback PAT
|
||||
value: ${{ steps.select-token.outputs.token }}
|
||||
source:
|
||||
description: Selected authentication source
|
||||
value: ${{ steps.select-token.outputs.source }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Validate authentication mode
|
||||
shell: bash
|
||||
env:
|
||||
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
|
||||
run: |
|
||||
if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then
|
||||
echo "::error::Unsupported GitHub authentication mode."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign in to Azure
|
||||
id: azure-login
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
|
||||
continue-on-error: true
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Create GitHub App installation token
|
||||
id: app-token
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
KEY_VAULT_NAME: ${{ inputs.key-vault-name }}
|
||||
KEY_NAME: ${{ inputs.key-name }}
|
||||
GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }}
|
||||
GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }}
|
||||
TARGET_REPOSITORY: ${{ inputs.repository }}
|
||||
run: |
|
||||
token="$(node "$GITHUB_ACTION_PATH/create-token.js")"
|
||||
echo "::add-mask::$token"
|
||||
echo "token=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select authentication token
|
||||
id: select-token
|
||||
shell: bash
|
||||
env:
|
||||
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
FALLBACK_TOKEN: ${{ inputs.fallback-token }}
|
||||
run: |
|
||||
if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then
|
||||
token="$APP_TOKEN"
|
||||
source="app"
|
||||
echo "::notice::GitHub authentication source: app"
|
||||
elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then
|
||||
token="$FALLBACK_TOKEN"
|
||||
source="pat-fallback"
|
||||
echo "::warning::GitHub authentication source: PAT fallback"
|
||||
elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then
|
||||
token="$FALLBACK_TOKEN"
|
||||
source="pat-forced"
|
||||
echo "::warning::GitHub authentication source: PAT (forced rollout mode)"
|
||||
else
|
||||
echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::$token"
|
||||
echo "token=$token" >> "$GITHUB_OUTPUT"
|
||||
echo "source=$source" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function base64Url(value) {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
function base64ToBase64Url(value) {
|
||||
return Buffer.from(value, 'base64').toString('base64url');
|
||||
}
|
||||
|
||||
function createJwtSigningInput(clientId, nowSeconds) {
|
||||
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const payload = base64Url(JSON.stringify({
|
||||
iat: nowSeconds - 60,
|
||||
exp: nowSeconds + 540,
|
||||
iss: clientId,
|
||||
}));
|
||||
return `${header}.${payload}`;
|
||||
}
|
||||
|
||||
function signJwt(signingInput, config, execute = execFileSync) {
|
||||
const digest = crypto.createHash('sha256').update(signingInput).digest('base64');
|
||||
const signature = execute(
|
||||
'az',
|
||||
[
|
||||
'keyvault', 'key', 'sign',
|
||||
'--subscription', config.azureSubscriptionId,
|
||||
'--vault-name', config.keyVaultName,
|
||||
'--name', config.keyName,
|
||||
'--algorithm', 'RS256',
|
||||
'--digest', digest,
|
||||
'--query', 'signature',
|
||||
'--output', 'tsv',
|
||||
'--only-show-errors',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
|
||||
if (!signature) {
|
||||
throw new Error('Key Vault returned an empty signature.');
|
||||
}
|
||||
|
||||
return `${signingInput}.${base64ToBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
async function createInstallationToken(config, dependencies = {}) {
|
||||
const execute = dependencies.execute ?? execFileSync;
|
||||
const request = dependencies.fetch ?? fetch;
|
||||
const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000);
|
||||
const repositoryParts = config.targetRepository.split('/');
|
||||
|
||||
if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) {
|
||||
throw new Error('TARGET_REPOSITORY must use the owner/repository format.');
|
||||
}
|
||||
|
||||
const [, repository] = repositoryParts;
|
||||
const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds);
|
||||
const jwt = signJwt(signingInput, config, execute);
|
||||
|
||||
const response = await request(
|
||||
`https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repositories: [repository],
|
||||
permissions: {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
members: 'read',
|
||||
pull_requests: 'write',
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (typeof result.token !== 'string' || result.token.length === 0) {
|
||||
throw new Error('GitHub returned an empty installation token.');
|
||||
}
|
||||
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function readConfig(environment) {
|
||||
const config = {
|
||||
azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID,
|
||||
keyVaultName: environment.KEY_VAULT_NAME,
|
||||
keyName: environment.KEY_NAME,
|
||||
githubAppClientId: environment.GITHUB_APP_CLIENT_ID,
|
||||
githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID,
|
||||
targetRepository: environment.TARGET_REPOSITORY,
|
||||
};
|
||||
|
||||
if (Object.values(config).some((value) => !value)) {
|
||||
throw new Error('Required GitHub App authentication configuration is missing.');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const token = await createInstallationToken(readConfig(process.env));
|
||||
process.stdout.write(token);
|
||||
} catch {
|
||||
console.error('GitHub App token generation failed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
base64ToBase64Url,
|
||||
createInstallationToken,
|
||||
createJwtSigningInput,
|
||||
readConfig,
|
||||
signJwt,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
@@ -46,4 +46,4 @@ runs:
|
||||
- name: Install the project
|
||||
shell: bash
|
||||
run: |
|
||||
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
|
||||
cd python && uv sync --all-packages --all-extras --all-groups --prerelease=if-necessary-or-explicit
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue author and check their team membership.
|
||||
* Resolve the issue or pull request author and check their team membership.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.github - Octokit REST client from actions/github-script
|
||||
* @param {object} opts.context - GitHub Actions context
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue number to resolve author for
|
||||
* @param {string|number} opts.issueNumber - Issue or pull request number to resolve author for
|
||||
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
|
||||
*/
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
|
||||
let author = context.payload.issue?.user?.login;
|
||||
let author =
|
||||
context.payload.issue?.user?.login ??
|
||||
context.payload.pull_request?.user?.login;
|
||||
|
||||
if (!author) {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(issueNumber),
|
||||
});
|
||||
author = issue.user?.login;
|
||||
const number = Number(issueNumber);
|
||||
if (context.payload.pull_request) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: number,
|
||||
});
|
||||
author = pr.user?.login;
|
||||
} else {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: number,
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
||||
const BRANCH_PATTERN = /^[a-zA-Z0-9_./-]+$/;
|
||||
|
||||
function assertValidSha(sha, description) {
|
||||
if (!SHA_PATTERN.test(sha)) {
|
||||
throw new Error(`GitHub returned an invalid ${description} SHA.`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasWritePermission(permissionData) {
|
||||
return permissionData.user?.permissions?.push === true
|
||||
|| ['admin', 'maintain', 'write'].includes(permissionData.permission);
|
||||
}
|
||||
|
||||
function latestDecisiveReviews(reviews) {
|
||||
const latestByReviewer = new Map();
|
||||
const sortedReviews = [...reviews].sort((left, right) => {
|
||||
const submittedComparison = (left.submitted_at || '').localeCompare(right.submitted_at || '');
|
||||
return submittedComparison || Number(left.id) - Number(right.id);
|
||||
});
|
||||
|
||||
for (const review of sortedReviews) {
|
||||
const state = review.state?.toUpperCase();
|
||||
const reviewer = review.user?.login?.toLowerCase();
|
||||
if (reviewer && DECISIVE_REVIEW_STATES.has(state)) {
|
||||
latestByReviewer.set(reviewer, review);
|
||||
}
|
||||
}
|
||||
|
||||
return latestByReviewer;
|
||||
}
|
||||
|
||||
async function resolvePullRequest({ github, context, core, prNumber, requiredApprovals }) {
|
||||
if (!/^[0-9]+$/.test(prNumber)) {
|
||||
throw new Error('Invalid PR number. Only numeric values are allowed.');
|
||||
}
|
||||
|
||||
const pullNumber = Number(prNumber);
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
if (pullRequest.state !== 'open') {
|
||||
throw new Error(`PR #${pullNumber} is not open (state: ${pullRequest.state}).`);
|
||||
}
|
||||
|
||||
const headSha = pullRequest.head.sha;
|
||||
const baseSha = pullRequest.base.sha;
|
||||
assertValidSha(headSha, 'PR head');
|
||||
assertValidSha(baseSha, 'PR base');
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
...context.repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const latestReviews = latestDecisiveReviews(reviews);
|
||||
const author = pullRequest.user?.login?.toLowerCase();
|
||||
const approvalCandidates = [...latestReviews.entries()]
|
||||
.filter(([, review]) => review.state.toUpperCase() === 'APPROVED')
|
||||
.filter(([, review]) => review.commit_id === headSha)
|
||||
.filter(([reviewer]) => reviewer !== author);
|
||||
|
||||
const approvedMaintainers = [];
|
||||
for (const [reviewer] of approvalCandidates) {
|
||||
const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
...context.repo,
|
||||
username: reviewer,
|
||||
});
|
||||
if (hasWritePermission(permissionData)) {
|
||||
approvedMaintainers.push(reviewer);
|
||||
} else {
|
||||
core.info(`Ignoring approval from ${reviewer}: reviewer does not have write permission.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (approvedMaintainers.length < requiredApprovals) {
|
||||
throw new Error(
|
||||
`PR #${pullNumber} head ${headSha} requires ${requiredApprovals} approvals from unique `
|
||||
+ `write-capable maintainers; found ${approvedMaintainers.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
core.info(
|
||||
`PR #${pullNumber} head ${headSha} approved by: ${approvedMaintainers.join(', ')}.`,
|
||||
);
|
||||
return {
|
||||
baseRef: baseSha,
|
||||
checkoutRef: headSha,
|
||||
description: `PR #${pullNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBranch({ github, context, core, branch }) {
|
||||
if (!BRANCH_PATTERN.test(branch)) {
|
||||
throw new Error(
|
||||
'Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes '
|
||||
+ 'are allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const [{ data: repository }, { data: targetBranch }] = await Promise.all([
|
||||
github.rest.repos.get(context.repo),
|
||||
github.rest.repos.getBranch({ ...context.repo, branch }),
|
||||
]);
|
||||
const { data: baseBranch } = await github.rest.repos.getBranch({
|
||||
...context.repo,
|
||||
branch: repository.default_branch,
|
||||
});
|
||||
|
||||
const checkoutRef = targetBranch.commit.sha;
|
||||
const baseRef = baseBranch.commit.sha;
|
||||
assertValidSha(checkoutRef, 'branch head');
|
||||
assertValidSha(baseRef, 'default branch');
|
||||
core.info(`Branch ${branch} resolved to immutable commit ${checkoutRef}.`);
|
||||
|
||||
return {
|
||||
baseRef,
|
||||
checkoutRef,
|
||||
description: `branch ${branch}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manually requested integration-test target to an immutable commit.
|
||||
*
|
||||
* Pull requests must have fresh approvals from two unique write-capable
|
||||
* maintainers for the exact head commit. Branches are limited to branches in
|
||||
* the base repository and are pinned to their current commit.
|
||||
*/
|
||||
async function resolveIntegrationTestTarget({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber = '',
|
||||
branch = '',
|
||||
requiredApprovals = 2,
|
||||
}) {
|
||||
const normalizedPrNumber = prNumber.trim();
|
||||
const normalizedBranch = branch.trim();
|
||||
|
||||
if (normalizedPrNumber && normalizedBranch) {
|
||||
throw new Error('Please provide either a PR number or a branch name, not both.');
|
||||
}
|
||||
if (!normalizedPrNumber && !normalizedBranch) {
|
||||
throw new Error('Please provide either a PR number or a branch name.');
|
||||
}
|
||||
|
||||
if (normalizedPrNumber) {
|
||||
return resolvePullRequest({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber: normalizedPrNumber,
|
||||
requiredApprovals,
|
||||
});
|
||||
}
|
||||
return resolveBranch({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
branch: normalizedBranch,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = resolveIntegrationTestTarget;
|
||||
@@ -16,7 +16,12 @@ const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
function createMocks({
|
||||
payloadIssue = undefined,
|
||||
payloadPullRequest = undefined,
|
||||
apiUser = 'api-user',
|
||||
teamState = 'active',
|
||||
} = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
@@ -24,8 +29,16 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const payload = {};
|
||||
if (payloadIssue !== undefined) {
|
||||
payload.issue = payloadIssue;
|
||||
}
|
||||
if (payloadPullRequest !== undefined) {
|
||||
payload.pull_request = payloadPullRequest;
|
||||
}
|
||||
|
||||
const context = {
|
||||
payload: { issue: payloadIssue },
|
||||
payload,
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
@@ -36,6 +49,11 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
@@ -64,6 +82,37 @@ describe('author resolution', () => {
|
||||
assert.equal(result.author, 'payload-user');
|
||||
});
|
||||
|
||||
it('resolves author from pull_request event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadPullRequest: { user: { login: 'pr-author' } },
|
||||
});
|
||||
let issuesGetCalled = false;
|
||||
github.rest.issues.get = async () => {
|
||||
issuesGetCalled = true;
|
||||
return { data: { user: { login: 'api-user' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'pr-author');
|
||||
assert.equal(issuesGetCalled, false);
|
||||
});
|
||||
|
||||
it('resolves author via pulls API when pull_request payload user is null', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadPullRequest: { user: null },
|
||||
apiUser: 'fetched-pr-author',
|
||||
});
|
||||
let pullsGetCalled = false;
|
||||
github.rest.pulls.get = async () => {
|
||||
pullsGetCalled = true;
|
||||
return { data: { user: { login: 'fetched-pr-author' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'fetched-pr-author');
|
||||
assert.equal(pullsGetCalled, true);
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue is absent', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: 'api-user' });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
base64ToBase64Url,
|
||||
createInstallationToken,
|
||||
createJwtSigningInput,
|
||||
readConfig,
|
||||
} = require('../actions/github-app-token/create-token.js');
|
||||
|
||||
const CONFIG = {
|
||||
azureSubscriptionId: 'subscription-id',
|
||||
keyVaultName: 'vault-name',
|
||||
keyName: 'key-name',
|
||||
githubAppClientId: 'client-id',
|
||||
githubAppInstallationId: '12345',
|
||||
targetRepository: 'microsoft/agent-framework',
|
||||
};
|
||||
|
||||
describe('GitHub App token creation', () => {
|
||||
it('creates a short-lived GitHub App JWT', () => {
|
||||
const signingInput = createJwtSigningInput('client-id', 1_000);
|
||||
const [encodedHeader, encodedPayload] = signingInput.split('.');
|
||||
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString());
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString());
|
||||
|
||||
assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' });
|
||||
assert.deepEqual(payload, { iat: 940, exp: 1_540, iss: 'client-id' });
|
||||
});
|
||||
|
||||
it('converts Key Vault signatures to unpadded base64url', () => {
|
||||
assert.equal(base64ToBase64Url('+/8='), '-_8');
|
||||
});
|
||||
|
||||
it('requests a repository-scoped installation token', async () => {
|
||||
let request;
|
||||
const token = await createInstallationToken(CONFIG, {
|
||||
nowSeconds: 1_000,
|
||||
execute: (command, args) => {
|
||||
assert.equal(command, 'az');
|
||||
assert.ok(args.includes('RS256'));
|
||||
return '+/8=\n';
|
||||
},
|
||||
fetch: async (url, options) => {
|
||||
request = { url, options };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ token: 'installation-token' }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(token, 'installation-token');
|
||||
assert.equal(request.url, 'https://api.github.com/app/installations/12345/access_tokens');
|
||||
assert.match(request.options.headers.Authorization, /^Bearer [^.]+\.[^.]+\.-_8$/);
|
||||
assert.deepEqual(JSON.parse(request.options.body), {
|
||||
repositories: ['agent-framework'],
|
||||
permissions: {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
members: 'read',
|
||||
pull_requests: 'write',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete configuration', () => {
|
||||
assert.throws(
|
||||
() => readConfig({}),
|
||||
/Required GitHub App authentication configuration is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects repository values with extra path segments before signing', async () => {
|
||||
let signed = false;
|
||||
|
||||
await assert.rejects(
|
||||
createInstallationToken(
|
||||
{ ...CONFIG, targetRepository: 'microsoft/agent-framework/extra' },
|
||||
{
|
||||
execute: () => {
|
||||
signed = true;
|
||||
return '+/8=\n';
|
||||
},
|
||||
},
|
||||
),
|
||||
/TARGET_REPOSITORY must use the owner\/repository format/,
|
||||
);
|
||||
assert.equal(signed, false);
|
||||
});
|
||||
|
||||
it('rejects an empty Key Vault signature', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '\n',
|
||||
}),
|
||||
/Key Vault returned an empty signature/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a failed GitHub token request', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '+/8=\n',
|
||||
fetch: async () => ({ ok: false, status: 403 }),
|
||||
}),
|
||||
/GitHub installation token request failed with HTTP 403/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty GitHub installation token', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '+/8=\n',
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: '' }),
|
||||
}),
|
||||
}),
|
||||
/GitHub returned an empty installation token/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for resolve_integration_test_target.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_resolve_integration_test_target.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const resolveIntegrationTestTarget = require('../scripts/resolve_integration_test_target.js');
|
||||
|
||||
const HEAD_SHA = 'a'.repeat(40);
|
||||
const BASE_SHA = 'b'.repeat(40);
|
||||
|
||||
function review({
|
||||
id,
|
||||
login,
|
||||
state = 'APPROVED',
|
||||
commitId = HEAD_SHA,
|
||||
submittedAt = `2026-07-13T00:00:${String(id).padStart(2, '0')}Z`,
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
commit_id: commitId,
|
||||
submitted_at: submittedAt,
|
||||
user: { login },
|
||||
};
|
||||
}
|
||||
|
||||
function createMocks({
|
||||
pullState = 'open',
|
||||
pullAuthor = 'contributor',
|
||||
reviews = [],
|
||||
permissions = {},
|
||||
} = {}) {
|
||||
const core = {
|
||||
infoMessages: [],
|
||||
info(message) {
|
||||
this.infoMessages.push(message);
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
repo: { owner: 'microsoft', repo: 'agent-framework' },
|
||||
};
|
||||
const github = {
|
||||
paginate: async () => reviews,
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
state: pullState,
|
||||
user: { login: pullAuthor },
|
||||
head: { sha: HEAD_SHA },
|
||||
base: { sha: BASE_SHA },
|
||||
},
|
||||
}),
|
||||
listReviews: async () => {},
|
||||
},
|
||||
repos: {
|
||||
get: async () => ({ data: { default_branch: 'main' } }),
|
||||
getBranch: async ({ branch }) => ({
|
||||
data: { commit: { sha: branch === 'main' ? BASE_SHA : HEAD_SHA } },
|
||||
}),
|
||||
getCollaboratorPermissionLevel: async ({ username }) => ({
|
||||
data: permissions[username] || {
|
||||
permission: 'read',
|
||||
user: { permissions: { push: false } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { core, context, github };
|
||||
}
|
||||
|
||||
const WRITE_PERMISSION = {
|
||||
permission: 'write',
|
||||
user: { permissions: { push: true } },
|
||||
};
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects missing and conflicting targets', async () => {
|
||||
const mocks = createMocks();
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget(mocks),
|
||||
/provide either a PR number or a branch name/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1', branch: 'feature' }),
|
||||
/not both/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid PR numbers and branch names', async () => {
|
||||
const mocks = createMocks();
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1;echo' }),
|
||||
/Invalid PR number/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, branch: 'feature branch' }),
|
||||
/Invalid branch name/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pull request resolution', () => {
|
||||
it('pins an open PR with two fresh write-capable approvals', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'maintainer-one' }),
|
||||
review({ id: 2, login: 'maintainer-two' }),
|
||||
],
|
||||
permissions: {
|
||||
'maintainer-one': WRITE_PERMISSION,
|
||||
'maintainer-two': WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
baseRef: BASE_SHA,
|
||||
checkoutRef: HEAD_SHA,
|
||||
description: 'PR #123',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects closed PRs', async () => {
|
||||
const mocks = createMocks({ pullState: 'closed' });
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/is not open/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale, self, and read-only approvals', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'stale', commitId: 'c'.repeat(40) }),
|
||||
review({ id: 2, login: 'contributor' }),
|
||||
review({ id: 3, login: 'reader' }),
|
||||
review({ id: 4, login: 'maintainer' }),
|
||||
],
|
||||
permissions: {
|
||||
contributor: WRITE_PERMISSION,
|
||||
reader: { permission: 'read', user: { permissions: { push: false } } },
|
||||
maintainer: WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/found 1/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses each reviewer latest decisive review and ignores later comments', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'changes-requested' }),
|
||||
review({ id: 2, login: 'changes-requested', state: 'CHANGES_REQUESTED' }),
|
||||
review({ id: 3, login: 'maintainer-one' }),
|
||||
review({ id: 4, login: 'maintainer-one', state: 'COMMENTED' }),
|
||||
review({ id: 5, login: 'maintainer-two' }),
|
||||
],
|
||||
permissions: {
|
||||
'changes-requested': WRITE_PERMISSION,
|
||||
'maintainer-one': WRITE_PERMISSION,
|
||||
'maintainer-two': WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
|
||||
assert.equal(result.checkoutRef, HEAD_SHA);
|
||||
});
|
||||
|
||||
it('does not count a dismissed approval', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'dismissed', state: 'DISMISSED' }),
|
||||
review({ id: 2, login: 'maintainer' }),
|
||||
],
|
||||
permissions: {
|
||||
dismissed: WRITE_PERMISSION,
|
||||
maintainer: WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/found 1/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch resolution', () => {
|
||||
it('pins base-repository branches and their comparison base to SHAs', async () => {
|
||||
const mocks = createMocks();
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, branch: 'feature/test' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
baseRef: BASE_SHA,
|
||||
checkoutRef: HEAD_SHA,
|
||||
description: 'branch feature/test',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,16 +23,16 @@ For each project that needs to be migrated, you need to do the following:
|
||||
- Identify the specific Semantic Kernel agent types being used:
|
||||
- `ChatCompletionAgent` → `ChatClientAgent`
|
||||
- `OpenAIAssistantAgent` → `assistantsClient.CreateAIAgent()` (via OpenAI Assistants client extension)
|
||||
- `AzureAIAgent` → `persistentAgentsClient.CreateAIAgent()` (via Azure AI Foundry client extension)
|
||||
- `AzureAIAgent` → `persistentAgentsClient.CreateAIAgent()` (via Microsoft Foundry client extension)
|
||||
- `OpenAIResponseAgent` → `responsesClient.CreateAIAgent()` (via OpenAI Responses client extension)
|
||||
- `A2AAgent` → `AIAgent` (via A2A card resolver)
|
||||
- `BedrockAgent` → Custom implementation required (not supported)
|
||||
- Determine if agents are being created new or retrieved from hosted services:
|
||||
- **New agents**: Use `CreateAIAgent()` methods
|
||||
- **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Azure AI Foundry
|
||||
- **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Microsoft Foundry
|
||||
</agent_type_identification>
|
||||
|
||||
- Determine the AI provider being used (OpenAI, Azure OpenAI, Azure AI Foundry, etc.)
|
||||
- Determine the AI provider being used (OpenAI, Azure OpenAI, Microsoft Foundry, etc.)
|
||||
- Analyze tool/function registration patterns
|
||||
- Review thread management and invocation patterns
|
||||
|
||||
@@ -90,7 +90,7 @@ below in wrong order or skip any of them):
|
||||
you generate report when migration complete. Report should contain:
|
||||
- all project dependencies changes (mention what was changed, added or removed, including provider-specific packages)
|
||||
- all code files that were changed (mention what was changed in the file, if it was not changed, just mention that the file was not changed)
|
||||
- provider-specific migration patterns used (OpenAI, Azure OpenAI, Azure AI Foundry, A2A, ONNX, etc.)
|
||||
- provider-specific migration patterns used (OpenAI, Azure OpenAI, Microsoft Foundry, A2A, ONNX, etc.)
|
||||
- all cases where you could not convert the code because of unsupported features and you were unable to find a workaround
|
||||
- unsupported providers that require custom implementation (Bedrock, CopilotStudio)
|
||||
- breaking glass pattern migrations (InnerContent → RawRepresentation) and any CodeInterpreter or advanced tool usage
|
||||
@@ -223,7 +223,7 @@ using Microsoft.Agents.AI;
|
||||
// Provider-specific namespaces (add only if needed):
|
||||
using OpenAI; // For OpenAI provider
|
||||
using Azure.AI.OpenAI; // For Azure OpenAI provider
|
||||
using Azure.AI.Agents.Persistent; // For Azure AI Foundry provider
|
||||
using Azure.AI.Agents.Persistent; // For Microsoft Foundry provider
|
||||
using Azure.Identity; // For Azure authentication
|
||||
```
|
||||
</configuration_changes>
|
||||
@@ -499,7 +499,7 @@ For every thread created if there's intent to cleanup, the caller should track a
|
||||
var assistantClient = new OpenAIClient(apiKey).GetAssistantClient();
|
||||
await assistantClient.DeleteThreadAsync(thread.ConversationId);
|
||||
|
||||
// For Azure AI Foundry (when cleanup is needed):
|
||||
// For Microsoft Foundry (when cleanup is needed):
|
||||
var persistentClient = new PersistentAgentsClient(endpoint, credential);
|
||||
await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId);
|
||||
|
||||
@@ -514,7 +514,7 @@ await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId);
|
||||
1. Remove `thread.DeleteAsync()` calls
|
||||
2. Use provider-specific client for cleanup when required
|
||||
3. Access thread ID via `thread.ConversationId` property
|
||||
4. Only implement cleanup for providers that require it (Assistants, Azure AI Foundry)
|
||||
4. Only implement cleanup for providers that require it (Assistants, Microsoft Foundry)
|
||||
</api_changes>
|
||||
|
||||
### Provider-Specific Creation Patterns
|
||||
@@ -550,13 +550,13 @@ AIAgent agent = new AzureOpenAIClient(endpoint, credential)
|
||||
.CreateAIAgent(instructions: instructions);
|
||||
```
|
||||
|
||||
**Azure AI Foundry (New):**
|
||||
**Microsoft Foundry (New):**
|
||||
```csharp
|
||||
AIAgent agent = new PersistentAgentsClient(endpoint, credential)
|
||||
.CreateAIAgent(model: deploymentName, instructions: instructions);
|
||||
```
|
||||
|
||||
**Azure AI Foundry (Existing):**
|
||||
**Microsoft Foundry (Existing):**
|
||||
```csharp
|
||||
AIAgent agent = await new PersistentAgentsClient(endpoint, credential)
|
||||
.GetAIAgentAsync(agentId);
|
||||
@@ -1079,7 +1079,7 @@ AgentThread thread = agent.GetNewThread();
|
||||
```
|
||||
</api_changes>
|
||||
|
||||
### 4. Azure AI Foundry (AzureAIAgent) Migration
|
||||
### 4. Microsoft Foundry (AzureAIAgent) Migration
|
||||
|
||||
<configuration_changes>
|
||||
**Remove Semantic Kernel Packages:**
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -64,6 +64,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -15,6 +15,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -31,6 +32,7 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
pr_number: ${{ steps.pr.outputs.pr_number }}
|
||||
@@ -64,6 +66,31 @@ jobs:
|
||||
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts/check_team_membership.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
@@ -71,31 +98,16 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
let author = context.payload.pull_request?.user?.login;
|
||||
if (!author) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(process.env.PR_NUMBER),
|
||||
});
|
||||
author = pr.user.login;
|
||||
}
|
||||
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
console.log(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
isTeamMember = false;
|
||||
}
|
||||
|
||||
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; proceeding with review.`);
|
||||
@@ -107,6 +119,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
copilot-requests: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 60
|
||||
# Advisory check: failures here should not block the PR. The reviewer
|
||||
# posts comments as a best-effort signal; if the pipeline breaks, the
|
||||
@@ -140,7 +157,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
@@ -154,7 +171,7 @@ jobs:
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ github.token }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
PR_URL: ${{ needs.team_check.outputs.pr_url }}
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -163,6 +163,7 @@ jobs:
|
||||
# Change to project directory to ensure local nuget.config is used
|
||||
pushd consoleapp
|
||||
dotnet add packcheck.csproj package Microsoft.Agents.AI --prerelease
|
||||
dotnet add packcheck.csproj package Microsoft.Agents.AI.LocalCodeAct --prerelease
|
||||
dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj
|
||||
|
||||
# Clean up
|
||||
@@ -312,7 +313,7 @@ jobs:
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
|
||||
# Azure AI Foundry
|
||||
# Microsoft Foundry
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
@@ -528,7 +529,7 @@ jobs:
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
|
||||
# Azure AI Foundry
|
||||
# Microsoft Foundry
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
@@ -615,7 +616,7 @@ jobs:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
@@ -632,7 +633,7 @@ jobs:
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
|
||||
@@ -9,16 +9,31 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
description: "Immutable commit SHA to check out"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TENANT_ID:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZUREAI__ENDPOINT:
|
||||
required: true
|
||||
COPILOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
dotnet-integration-tests:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
|
||||
# Azure AI Foundry
|
||||
# Microsoft Foundry
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: GitHub automation tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Run tests
|
||||
run: node --test .github/tests/*.js
|
||||
@@ -3,7 +3,7 @@
|
||||
# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name.
|
||||
#
|
||||
# It calls dedicated integration-only workflows (dotnet-integration-tests and python-integration-tests),
|
||||
# passing a ref so they check out and test the correct code.
|
||||
# passing an immutable commit SHA so they check out and test the approved code.
|
||||
# Changed paths are detected here so only the relevant test suites run.
|
||||
#
|
||||
|
||||
@@ -26,7 +26,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
|
||||
@@ -38,67 +37,50 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
base-ref: ${{ steps.resolve.outputs.base-ref }}
|
||||
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
|
||||
python-changes: ${{ steps.detect-changes.outputs.python }}
|
||||
steps:
|
||||
- name: Resolve checkout ref
|
||||
- name: Check out trusted workflow helpers
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Resolve and authorize checkout ref
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const resolveIntegrationTestTarget = require(
|
||||
'./.github/scripts/resolve_integration_test_target.js'
|
||||
);
|
||||
const target = await resolveIntegrationTestTarget({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber: process.env.PR_NUMBER,
|
||||
branch: process.env.BRANCH,
|
||||
});
|
||||
core.setOutput('checkout-ref', target.checkoutRef);
|
||||
core.setOutput('base-ref', target.baseRef);
|
||||
core.info(`Running integration tests for ${target.description} at ${target.checkoutRef}.`);
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name, not both."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
|
||||
echo "::error::Invalid PR number. Only numeric values are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
|
||||
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
|
||||
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for PR #$PR_NUMBER"
|
||||
else
|
||||
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
|
||||
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for branch $BRANCH"
|
||||
fi
|
||||
|
||||
- name: Detect changed paths
|
||||
id: detect-changes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
BASE_REF: ${{ steps.resolve.outputs.base-ref }}
|
||||
CHECKOUT_REF: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
|
||||
else
|
||||
# For branches, compare against main using the GitHub API
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
|
||||
fi
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/$BASE_REF...$CHECKOUT_REF" \
|
||||
--jq '.files[].filename')
|
||||
|
||||
DOTNET_CHANGES=false
|
||||
PYTHON_CHANGES=false
|
||||
@@ -113,22 +95,41 @@ jobs:
|
||||
|
||||
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected changes — dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
echo "Detected changes; dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
|
||||
dotnet-integration-tests:
|
||||
name: .NET Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/dotnet-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZUREAI__ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
python-integration-tests:
|
||||
name: Python Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.python-changes == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/python-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -3,6 +3,12 @@ name: Issue Triage
|
||||
on:
|
||||
issues:
|
||||
types: [opened, typed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: Issue number to triage
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,7 +18,8 @@ permissions:
|
||||
concurrency:
|
||||
group: >-
|
||||
issue-triage-${{ github.repository }}-${{
|
||||
github.event.issue.type.name == 'Bug' && github.event.issue.number
|
||||
github.event_name == 'workflow_dispatch' && inputs.issue_number
|
||||
|| github.event.issue.type.name == 'Bug' && github.event.issue.number
|
||||
|| github.run_id
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
@@ -26,7 +33,12 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.type.name == 'Bug' }}
|
||||
environment: github-app-auth
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| github.event.issue.type.name == 'Bug'
|
||||
}}
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||
@@ -36,14 +48,18 @@ jobs:
|
||||
id: issue
|
||||
shell: bash
|
||||
env:
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
ISSUE_NUMBER: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' && inputs.issue_number
|
||||
|| github.event.issue.number
|
||||
}}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
issue_number="${ISSUE_NUMBER_EVENT}"
|
||||
issue_number="${ISSUE_NUMBER}"
|
||||
|
||||
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Could not determine issue number from event payload." >&2
|
||||
echo "Could not determine issue number from event payload or manual input." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -53,18 +69,36 @@ jobs:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check issue author team membership
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -84,8 +118,17 @@ jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| needs.team_check.outputs.is_team_member == 'false'
|
||||
}}
|
||||
environment: integration
|
||||
permissions:
|
||||
contents: read
|
||||
copilot-requests: write
|
||||
id-token: write
|
||||
issues: write
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
@@ -114,7 +157,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
@@ -160,7 +203,7 @@ jobs:
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ github.token }}
|
||||
# Not seen by the agent prompt; used only to push a paper-trail
|
||||
# branch back to maf-dashboard at run end.
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
|
||||
@@ -10,12 +10,39 @@ jobs:
|
||||
name: "Issue: add labels"
|
||||
if: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts/check_team_membership.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
// Get the issue body and title
|
||||
const body = context.payload.issue.body
|
||||
@@ -24,21 +51,14 @@ jobs:
|
||||
// Define the labels array
|
||||
let labels = []
|
||||
|
||||
// Check if the issue author is in the agentframework-developers team
|
||||
let isTeamMember = false
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: context.payload.issue.user.login
|
||||
})
|
||||
console.log("Team Membership Data:", teamMembership);
|
||||
isTeamMember = teamMembership.data.state === 'active'
|
||||
} catch (error) {
|
||||
// User is not in the team or team doesn't exist
|
||||
console.error("Error fetching team membership:", error);
|
||||
isTeamMember = false
|
||||
}
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js')
|
||||
const { isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: context.issue.number,
|
||||
})
|
||||
|
||||
// Only add triage label if the author is not in the team
|
||||
if (!isTeamMember) {
|
||||
|
||||
@@ -13,27 +13,47 @@ on:
|
||||
jobs:
|
||||
add_label:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: ${{ steps.github-auth.outputs.token }}
|
||||
|
||||
- name: "PR: add breaking change label from title"
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const { syncBreakingChangeLabelFromTitle } = require('./.github/scripts/title_prefix.js');
|
||||
await syncBreakingChangeLabelFromTitle({ github, context, core });
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -21,16 +22,35 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
@@ -38,7 +58,7 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -57,20 +77,39 @@ jobs:
|
||||
|
||||
limit_open_prs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
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
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Enforce open PR limit
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
|
||||
await enforcePrLimit({
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version-file: "python/pyproject.toml"
|
||||
enable-cache: true
|
||||
|
||||
@@ -13,13 +13,27 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
description: "Immutable commit SHA to check out"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY:
|
||||
required: true
|
||||
AZURE_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TENANT_ID:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
COPILOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
FOUNDRY_MODELS_API_KEY:
|
||||
required: false
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -99,6 +113,9 @@ jobs:
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
name: Python Integration Tests - Azure OpenAI
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -224,6 +241,7 @@ jobs:
|
||||
packages/hyperlight/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
packages/hosting-mcp/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -260,6 +278,9 @@ jobs:
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
name: Python Integration Tests - Functions
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -324,6 +345,9 @@ jobs:
|
||||
# Foundry integration tests
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -378,6 +402,9 @@ jobs:
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -551,7 +578,7 @@ jobs:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
@@ -568,7 +595,7 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -71,6 +71,7 @@ jobs:
|
||||
- 'python/packages/ollama/**'
|
||||
- 'python/packages/core/agent_framework/_mcp.py'
|
||||
- 'python/packages/core/tests/core/test_mcp.py'
|
||||
- 'python/packages/hosting-mcp/**'
|
||||
- 'python/scripts/local_mcp_streamable_http_server.py'
|
||||
- '.github/actions/setup-local-mcp-server/**'
|
||||
- '.github/workflows/python-merge-tests.yml'
|
||||
@@ -345,6 +346,7 @@ jobs:
|
||||
packages/hyperlight/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
packages/hosting-mcp/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -748,7 +750,7 @@ jobs:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
@@ -765,7 +767,7 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Build the package
|
||||
run: uv run poe --directory packages/${{ env.PACKAGE }} build
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
files: |
|
||||
python/dist/*
|
||||
|
||||
@@ -701,7 +701,7 @@ jobs:
|
||||
|
||||
- name: Restore validation history
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
@@ -719,7 +719,7 @@ jobs:
|
||||
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save validation history
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -26,13 +26,30 @@ jobs:
|
||||
ping_stale:
|
||||
name: "Ping stale issues and PRs"
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
@@ -43,7 +60,7 @@ jobs:
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**What is Microsoft Agent Framework?**
|
||||
|
||||
Microsoft Agent Framework is a comprehensive multi-language (C#/.NET and Python) framework for building, orchestrating, and deploying AI agents and multi-agent workflows. The system takes user instructions and conversation inputs and produces intelligent responses through AI agents that can integrate with various LLM providers (OpenAI, Azure OpenAI, Azure AI Foundry). It provides both simple chat agents and complex multi-agent workflows with graph-based orchestration.
|
||||
Microsoft Agent Framework is a comprehensive multi-language (C#/.NET and Python) framework for building, orchestrating, and deploying AI agents and multi-agent workflows. The system takes user instructions and conversation inputs and produces intelligent responses through AI agents that can integrate with various LLM providers (OpenAI, Azure OpenAI, Microsoft Foundry). It provides both simple chat agents and complex multi-agent workflows with graph-based orchestration.
|
||||
|
||||
**What can Microsoft Agent Framework do?**
|
||||
|
||||
@@ -12,7 +12,7 @@ The framework offers:
|
||||
- **Multi-Agent Orchestration**: Group chat, sequential, concurrent, and handoff patterns
|
||||
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, time-travel, and Human-in-the-loop
|
||||
- **Extensibility Framework**: Extend with native functions, A2A, Model Context Protocol (MCP)
|
||||
- **LLM Integration**: Support for OpenAI, Azure OpenAI, Azure AI Foundry, and other providers
|
||||
- **LLM Integration**: Support for OpenAI, Azure OpenAI, Microsoft Foundry, and other providers
|
||||
- **Runtime Support**: Both in-process and distributed agent execution
|
||||
|
||||
**What is/are Microsoft Agent Framework's intended use(s)?**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# These are optional elements. Feel free to remove any of them.
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
|
||||
date: 2025-07-10
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
|
||||
consulted:
|
||||
informed:
|
||||
@@ -139,7 +139,7 @@ Therefore something like `AgentResponse.Text` which also aggregates all `TextCon
|
||||
|
||||
#### Option 1.2 Presence of Secondary Content is determined by a runtime parameter
|
||||
|
||||
We can allow callers to choose whether to include secondary content in the list of reponse messages.
|
||||
We can allow callers to choose whether to include secondary content in the list of response messages.
|
||||
Open Question: Do we allow secondary content to use `TextContent` types?
|
||||
|
||||
```csharp
|
||||
|
||||
@@ -113,7 +113,7 @@ Implement a hybrid strategy where common tools use generic `AITool`-derived abst
|
||||
|
||||
### AI Agent Tool Types Availability
|
||||
|
||||
Tool Type | Azure AI Foundry Agent Service | OpenAI Assistant API | OpenAI ChatCompletion API | OpenAI Responses API | Amazon Bedrock Agents | Google | Anthropic | Description
|
||||
Tool Type | Microsoft Foundry Agent Service | OpenAI Assistant API | OpenAI ChatCompletion API | OpenAI Responses API | Amazon Bedrock Agents | Google | Anthropic | Description
|
||||
-- | -- | -- | -- | -- | -- | -- | -- | --
|
||||
Function Calling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Enables custom, stateless functions to define specific agent behaviors.
|
||||
Code Interpreter | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | Allows agents to execute code for tasks like data analysis or problem-solving.
|
||||
@@ -132,7 +132,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Function Calling
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/function-calling?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/function-calling?pivots=rest</a>
|
||||
|
||||
Message Request:
|
||||
@@ -401,7 +401,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Code Interpreter
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
<p>Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/code-interpreter-samples?pivots=rest-api">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/code-interpreter-samples?pivots=rest-api</a></p>
|
||||
|
||||
<p>.NET Support: ✅</p>
|
||||
@@ -709,7 +709,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Search and Retrieval
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/file-search-upload-files?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/file-search-upload-files?pivots=rest</a>
|
||||
|
||||
File Search Request:
|
||||
@@ -1083,7 +1083,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Web Search
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-code-samples?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-code-samples?pivots=rest</a>
|
||||
|
||||
Bing Search Message Request:
|
||||
@@ -1630,7 +1630,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### OpenAPI Spec Tool
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/openapi-spec-samples?pivots=rest-api">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/openapi-spec-samples?pivots=rest-api</a><br>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/rest/api/aifoundry/aiagents/run-steps/get-run-step?view=rest-aifoundry-aiagents-v1&tabs=HTTP#runstepopenapitoolcall">https://learn.microsoft.com/en-us/rest/api/aifoundry/aiagents/run-steps/get-run-step?view=rest-aifoundry-aiagents-v1&tabs=HTTP#runstepopenapitoolcall</a>
|
||||
|
||||
@@ -1712,7 +1712,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Stateful Functions
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/azure-functions-samples?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/azure-functions-samples?pivots=rest</a>
|
||||
|
||||
Message Request:
|
||||
@@ -1832,7 +1832,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
|
||||
|
||||
#### Microsoft Fabric
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agent Service</summary>
|
||||
<summary>Microsoft Foundry Agent Service</summary>
|
||||
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/fabric?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/fabric?pivots=rest</a>
|
||||
|
||||
Message Request:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# These are optional elements. Feel free to remove any of them.
|
||||
status: accepted
|
||||
contact: westey-m
|
||||
date: 2025-09-12 {YYYY-MM-DD when the decision was last updated}
|
||||
date: 2025-09-12
|
||||
deciders: sergeymenshykh, markwallace-microsoft, rogerbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub, peterychang
|
||||
consulted:
|
||||
informed:
|
||||
@@ -25,7 +25,7 @@ See various features that would need to be supported via this type of mechanism,
|
||||
- Also see [the openai human-in-the-loop guide](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/#approval-requests).
|
||||
- Also see [the openai MCP guide](https://openai.github.io/openai-agents-js/guides/mcp/#optional-approval-flow).
|
||||
- Also see [MCP Approval Requests from OpenAI](https://platform.openai.com/docs/guides/tools-remote-mcp#approvals).
|
||||
- Also see [Azure AI Foundry MCP Approvals](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/model-context-protocol-samples?pivots=rest#submit-your-approval).
|
||||
- Also see [Microsoft Foundry MCP Approvals](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/model-context-protocol-samples?pivots=rest#submit-your-approval).
|
||||
- Also see [MCP Elicitation requests](https://modelcontextprotocol.io/specification/draft/client/elicitation)
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
@@ -57,7 +57,7 @@ This section describes different options for various aspects required to add lon
|
||||
|
||||
### 1. Methods for Working with Long-Running Operations
|
||||
|
||||
Based on the analysis of existing APIs that support long-running operations (such as OpenAI Responses, Azure AI Foundry Agents, and A2A),
|
||||
Based on the analysis of existing APIs that support long-running operations (such as OpenAI Responses, Microsoft Foundry Agents, and A2A),
|
||||
the following operations are used for working with long-running operations:
|
||||
- Common operations:
|
||||
- **Start Long-Running Execution**: Initiates a long-running operation and returns its Id.
|
||||
@@ -757,7 +757,7 @@ Some of them natively support resuming streaming from a specific point in the st
|
||||
| API | Can Resume Streaming | Model |
|
||||
|-------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| OpenAI Responses | Yes | StreamingResponseUpdate.**SequenceNumber** + GetResponseStreamingAsync(responseId, **startingAfter**, ct) |
|
||||
| Azure AI Foundry Agents | Emulated<sup>2</sup> | RunStep.**Id** + custom pseudo code: client.Runs.GetRunStepsAsync(...).AllStepsAfter(**stepId**) |
|
||||
| Microsoft Foundry Agents | Emulated<sup>2</sup> | RunStep.**Id** + custom pseudo code: client.Runs.GetRunStepsAsync(...).AllStepsAfter(**stepId**) |
|
||||
| A2A | Implementation dependent<sup>1</sup> | |
|
||||
|
||||
<sup>1</sup> The [A2A specification](https://github.com/a2aproject/A2A/blob/main/docs/topics/streaming-and-async.md#1-streaming-with-server-sent-events-sse)
|
||||
@@ -765,7 +765,7 @@ allows an A2A agent implementation to decide how to handle streaming resumption:
|
||||
a task is still active (and the server hasn't sent a final: true event for that phase), the client can attempt to reconnect to the stream using the tasks/resubscribe RPC method.
|
||||
The server's behavior regarding missed events during the disconnection period (e.g., whether it backfills or only sends new updates) is implementation-dependent._
|
||||
|
||||
<sup>2</sup> The Azure AI Foundry Agents API has an API to start a streaming run but does not have an API to resume streaming from a specific point in the stream.
|
||||
<sup>2</sup> The Microsoft Foundry Agents API has an API to start a streaming run but does not have an API to resume streaming from a specific point in the stream.
|
||||
However, it has non-streaming APIs to access already started runs, which can be used to emulate streaming resumption by accessing a run and its steps and streaming all the steps after a specific step.
|
||||
|
||||
#### Required Changes
|
||||
@@ -828,7 +828,7 @@ Sequence of updates from OpenAI Responses API to answer the question "What time
|
||||
| resp_2 | 10 | resp.output_item.done | - | InProgress | |
|
||||
| resp_2 | 11 | resp.completed | Completed | Completed | |
|
||||
|
||||
Sequence of updates from Azure AI Foundry Agents API to answer the question "What time is it?" using a function call:
|
||||
Sequence of updates from Microsoft Foundry Agents API to answer the question "What time is it?" using a function call:
|
||||
| Id | SN | UpdateKind | Run.Status | Step.Status | Message.Status | ChatResponseUpdate.Status | Description |
|
||||
|--------|---------|-------------------|----------------|-------------|-----------------|---------------------------|---------------------------------------------------|
|
||||
| run_1 | - | RunCreated | Queued | - | - | Queued | |
|
||||
@@ -852,7 +852,7 @@ Sequence of updates from Azure AI Foundry Agents API to answer the question "Wha
|
||||
|
||||
To support long-running operations, the following values need to be returned by the GetResponseAsync and GetStreamingResponseAsync methods:
|
||||
- `ResponseId` - identifier of the long-running operation or an entity representing it, such as a task.
|
||||
- `ConversationId` - identifier of the conversation or thread the long-running operation is part of. Some APIs, like Azure AI Foundry Agents, use
|
||||
- `ConversationId` - identifier of the conversation or thread the long-running operation is part of. Some APIs, like Microsoft Foundry Agents, use
|
||||
this identifier together with the ResponseId to identify a run.
|
||||
- `SequenceNumber` - identifier of an update within a stream of updates. This is required to support streaming resumption by the GetStreamingResponseAsync method only.
|
||||
- `Status` - status of the long-running operation: whether it is queued, running, failed, cancelled, completed, etc.
|
||||
@@ -1089,7 +1089,7 @@ public class ChatOptions
|
||||
|
||||
##### 6.1.5 Continuation Token of a Custom Type
|
||||
|
||||
The option is similar the the "6.1.3 Continuation Token of System.ClientModel.ContinuationToken Type" option but suggests using a
|
||||
The option is similar to the "6.1.3 Continuation Token of System.ClientModel.ContinuationToken Type" option but suggests using a
|
||||
custom type for the continuation token instead of the `System.ClientModel.ContinuationToken` type.
|
||||
|
||||
**Pros**
|
||||
@@ -1203,7 +1203,7 @@ response = await agent.CancelRunAsync(response.ResponseId, new AgentCancelRunOpt
|
||||
In case an agent supports either or both cancellation and deletion of long-running operations, it will override the corresponding methods.
|
||||
Otherwise, it won't override them, and the base implementations will return null by default.
|
||||
|
||||
Some agents, for example Azure AI Foundry Agents, require the thread identifier to cancel a run. To accommodate this requirement, the `CancelRunAsync` method
|
||||
Some agents, for example Microsoft Foundry Agents, require the thread identifier to cancel a run. To accommodate this requirement, the `CancelRunAsync` method
|
||||
accepts an optional `AgentCancelRunOptions` parameter that allows callers to specify the thread associated with the run they want to cancel.
|
||||
|
||||
```csharp
|
||||
@@ -1574,7 +1574,7 @@ the thread is provided with background operations consistently for all runs.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Azure AI Foundry Agents</summary>
|
||||
<summary>Microsoft Foundry Agents</summary>
|
||||
|
||||
- Create a thread and run the agent against it and wait for it to complete using polling:
|
||||
```csharp
|
||||
|
||||
@@ -34,11 +34,11 @@ Key changes:
|
||||
1. **New `agent-framework-openai` package** with dependencies on `agent-framework-core`, `openai`, and `packaging` only.
|
||||
2. **Class renames**: `OpenAIResponsesClient` → `OpenAIChatClient` (Responses API), `OpenAIChatClient` → `OpenAIChatCompletionClient` (Chat Completions API). Old names remain as deprecated aliases.
|
||||
3. **Deprecated classes**: `OpenAIAssistantsClient`, all `AzureOpenAI*Client` classes, `AzureAIClient`, `AzureAIAgentClient`, and `AzureAIProjectAgentProvider` are marked deprecated.
|
||||
4. **New `FoundryChatClient`** in azure-ai for Azure AI Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
|
||||
4. **New `FoundryChatClient`** in azure-ai for Microsoft Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
|
||||
5. **All deprecated `AzureOpenAI*` classes** consolidated into a single file (`_deprecated_azure_openai.py`) in the azure-ai package for clean future deletion.
|
||||
6. **Core's `agent_framework.openai` and `agent_framework.azure` namespaces** become lazy-loading gateways, preserving backward-compatible import paths while removing hard dependencies.
|
||||
7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_CHAT_MODEL_ID` / `OPENAI_CHAT_COMPLETION_MODEL_ID`).
|
||||
8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Azure AI Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
|
||||
8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Microsoft Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
|
||||
|
||||
### Foundry Agent Design: `FoundryAgentClient` vs `FoundryAgent`
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ consulted: Pratyush Mishra, Shivam Shrivastava, Manni Arora (Centrica eval scena
|
||||
informed: Agent Framework team, Foundry Evals team
|
||||
---
|
||||
|
||||
# Agent Evaluation Architecture with Azure AI Foundry Integration
|
||||
# Agent Evaluation Architecture with Microsoft Foundry Integration
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Azure AI Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.
|
||||
Microsoft Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.
|
||||
|
||||
However, using Foundry Evals with an agent-framework agent today requires significant manual effort. Developers must:
|
||||
|
||||
@@ -445,7 +445,7 @@ These factorings produce different scores for the same conversation. The framewo
|
||||
|
||||
### Azure AI: FoundryEvals
|
||||
|
||||
`Evaluator` implementation backed by Azure AI Foundry:
|
||||
`Evaluator` implementation backed by Microsoft Foundry:
|
||||
|
||||
```python
|
||||
class FoundryEvals:
|
||||
@@ -812,4 +812,4 @@ public sealed class EvalItem
|
||||
|
||||
## More Information
|
||||
|
||||
- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Azure AI Foundry evaluation overview
|
||||
- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Microsoft Foundry evaluation overview
|
||||
|
||||
@@ -9,7 +9,7 @@ deciders: evmattso
|
||||
|
||||
## What is the goal of this feature?
|
||||
|
||||
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in an Azure AI Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
|
||||
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in a Microsoft Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
|
||||
|
||||
A user who has configured a toolbox in the Foundry portal (or via the raw SDK) should be able to load it into an agent with a single call:
|
||||
|
||||
|
||||
@@ -181,8 +181,8 @@ The app chooses which helper to call for that route and deployment. For example:
|
||||
|
||||
- `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous
|
||||
response id or a `conv_*` conversation id when present;
|
||||
- `telegram_session_id(update)` from `agent-framework-hosting-telegram`, which can choose the chat, user, thread, or
|
||||
other Telegram-native partitioning logic for that helper;
|
||||
- `telegram_session_id(update, bot_id=...)` from `agent-framework-hosting-telegram`, which uses the bot and sender for
|
||||
private chats and the bot and chat for shared group sessions;
|
||||
- `activity_session_id(activity)`, `discord_session_id(interaction_or_message)`, or
|
||||
`a2a_session_id(request_context)` from their respective protocol packages;
|
||||
- `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`.
|
||||
@@ -204,7 +204,8 @@ safe to use:
|
||||
transient execution.
|
||||
|
||||
A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent
|
||||
target and creates the session on first use:
|
||||
target and creates the session on first use. Reads return independent working copies so running from one continuation
|
||||
point does not mutate the stored snapshot or another simultaneous branch:
|
||||
|
||||
For agent targets:
|
||||
|
||||
@@ -227,6 +228,11 @@ await state.set_session(response_id, session)
|
||||
`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call
|
||||
belongs after the run, not before it.
|
||||
|
||||
Response ids are immutable continuation points, so simultaneous callers can branch from one `previous_response_id` and
|
||||
store their completed sessions under different new response ids. A stable `conversation_id` is a mutable head: the app
|
||||
must explicitly update it after the run and provide single-writer coordination. The hosting state helper does not lock
|
||||
an entire run or resolve concurrent updates to that stable key.
|
||||
|
||||
The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any
|
||||
externally supplied key before using it.
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-07-08
|
||||
deciders: rogerbarreto
|
||||
consulted: eavanvalkenburg
|
||||
informed: []
|
||||
---
|
||||
|
||||
# .NET hosting: OpenAI Responses protocol helpers for app-owned routing
|
||||
|
||||
Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET.
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel
|
||||
framework toward **protocol conversion helpers plus optional execution state**: Agent Framework owns
|
||||
protocol-native <-> run conversion, while the application owns HTTP routing, authentication,
|
||||
middleware, storage, and native SDK calls.
|
||||
|
||||
.NET already ships `Microsoft.Agents.AI.Hosting.OpenAI`, a route-owning server that **exposes an
|
||||
`AIAgent` (or workflow) as the OpenAI Responses API** (`MapOpenAIResponses` + `IResponsesService`). It
|
||||
owns the routes, an in-memory response/conversation store, streaming, and lifecycle. The question is
|
||||
what, if anything, .NET must add to satisfy the ADR-0027 boundary.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Do not reinvent conversion logic that already exists and is battle-tested in `Hosting.OpenAI`.
|
||||
- Give applications a way to own their own route/auth/middleware/storage while reusing Agent Framework
|
||||
conversion (the ADR-0027 boundary).
|
||||
- Keep the released public surface small.
|
||||
- Stay consistent with the existing .NET hosting stack, which deliberately does **not** use the OpenAI
|
||||
SDK Responses types server-side (it hand-rolled its own wire model).
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Self-contained new package that reimplements conversion using the OpenAI SDK Responses types
|
||||
(mirrors the Python `agent-framework-hosting-responses` lineage).
|
||||
2. New package that reuses `Hosting.OpenAI`'s internal converters (via `InternalsVisibleTo` or by
|
||||
moving the conversion core out).
|
||||
3. Thin public helper facade **inside** `Hosting.OpenAI` over the existing internal converters, plus
|
||||
protocol-neutral execution-state holders in `Microsoft.Agents.AI.Hosting`.
|
||||
|
||||
### First-principles gap analysis
|
||||
|
||||
A capability comparison of the ADR-0027 / PR #6891 helper surface against the existing .NET stack:
|
||||
|
||||
| Python helper capability | .NET today | Status |
|
||||
| --- | --- | --- |
|
||||
| `responses_to_run` | `ResponseInput.GetInputMessages` + `InputMessage.ToChatMessage` + `OpenAIResponsesMapOptions.RunOptionsFactory` | exists, internal |
|
||||
| `responses_from_run` | `AgentResponseExtensions.ToResponse` | exists, internal |
|
||||
| `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer |
|
||||
| `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone |
|
||||
| `create_response_id` | `IdGenerator` | exists, internal |
|
||||
| `AgentState` (target + store, get-or-create, callable/awaitable target) | `AgentSessionStore` (get-or-create + save + serialize + isolation) + DI container (target lifetime + async setup) | create-on-miss lives in the store; per-run instance and deferred/async target come from DI, so no separate holder is needed |
|
||||
| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added |
|
||||
| `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor |
|
||||
| App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** |
|
||||
|
||||
.NET already covers ~90% of the capability, and more richly (its streaming renderer even emits workflow
|
||||
events; its session store serializes and supports per-principal isolation, neither of which Python's
|
||||
in-memory `SessionStore` does). The single genuine gap is the **ownership model**: every conversion
|
||||
primitive is bundled behind the route-owning server, so an application cannot own its own route and
|
||||
call just the conversion.
|
||||
|
||||
Note on lineage: Python's Responses offering was introduced *as a channel* (PR #6580) and always used
|
||||
the `openai` SDK Responses types. .NET's `Hosting.OpenAI` predates and is independent of channels and
|
||||
hand-rolled its own server-side wire DTOs (the SDK's Responses types are client-shaped and awkward
|
||||
server-side). So Option 1 would both reinvent a working asset and contradict the .NET codebase's own
|
||||
precedent.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **3. Thin public helper facade inside `Hosting.OpenAI` plus neutral state holders**,
|
||||
because the only real gap is the ownership model, so the work is to *un-bundle* the existing
|
||||
converters, not to rebuild them or add a package.
|
||||
|
||||
### Public surface
|
||||
|
||||
`Microsoft.Agents.AI.Hosting.OpenAI` gains a single public static facade, `OpenAIResponses`, whose
|
||||
boundary is `System.Text.Json` (`JsonElement`/streamed events), matching Python's dict boundary and
|
||||
keeping the hand-rolled wire DTOs internal:
|
||||
|
||||
- `OpenAIResponses.ToAgentRunRequest(JsonElement body)` -> messages + `AgentRunOptions?`.
|
||||
- `OpenAIResponses.WriteResponse(AgentRunResponse response, string responseId, string? sessionId = null)`
|
||||
-> a Responses-shaped `JsonElement`.
|
||||
- `OpenAIResponses.WriteResponseStreamAsync(IAsyncEnumerable<AgentRunResponseUpdate> updates, string responseId, ...)`
|
||||
-> Responses SSE `data:` frames.
|
||||
- `OpenAIResponses.GetSessionId(JsonElement body)` -> `previous_response_id` or `conversation` id, or
|
||||
`null`. Kept **separate** from `ToAgentRunRequest` so the trust boundary is visible: choosing to use
|
||||
a request-derived key is an explicit application decision.
|
||||
- `OpenAIResponses.CreateResponseId()` -> a `resp_*` id.
|
||||
|
||||
All helpers are side-effect-free and delegate to the existing internal converters. `MapOpenAIResponses`
|
||||
public behavior is unchanged; it and the facade share one internal conversion core (an internal
|
||||
`ToResponse` overload with an optional originating request is added so the facade can render without a
|
||||
request object).
|
||||
|
||||
### Optional execution state (neutral package)
|
||||
|
||||
`Microsoft.Agents.AI.Hosting` gains:
|
||||
|
||||
- `AgentSessionStore.DeleteSessionAsync(...)` (+ `InMemoryAgentSessionStore` implementation and
|
||||
isolation-decorator passthrough): the one missing store operation.
|
||||
- No agent-side holder. Applications use `AgentSessionStore` directly: `GetSessionAsync(agent, id)`
|
||||
already creates on miss and returns an independent session instance per call (so concurrent calls fork
|
||||
the same stored state rather than sharing an instance), `SaveSessionAsync(agent, id, session)` persists
|
||||
post-run (including under a newly minted id), and `DeleteSessionAsync(agent, id)` removes it. An earlier
|
||||
draft added a `HostedAgentState` holder, but once create-on-miss lives in the store and the store does no
|
||||
cross-call locking, the holder would only bind the `agent` argument, which is not enough to justify a
|
||||
public type. Any coordination for concurrent runs against the same id is the application's concern.
|
||||
(Unlike Python, whose `SessionStore` is get/set-only and whose `AgentState` therefore owns
|
||||
create-on-miss, .NET's store already owns it.)
|
||||
|
||||
Python's `AgentState` carries two further responsibilities beyond create-on-miss: it accepts a callable
|
||||
or awaitable target so the host can (1) obtain a fresh agent instance per run and (2) defer expensive or
|
||||
asynchronous agent setup while keeping server construction synchronous. In .NET these two concerns are
|
||||
owned by the dependency-injection container, not by a hosting type. Per-run lifetime is expressed by the
|
||||
registration lifetime (`AddScoped`/`AddTransient` yields a fresh `AIAgent` per request or scope, resolved
|
||||
by the framework), and deferred or asynchronous construction is expressed by an async factory registration
|
||||
(for example an `async` factory delegate, `ActivatorUtilities`, or resolving the agent inside the request
|
||||
after any async warm-up), so the route handler resolves an already-built agent from the container. An
|
||||
`AIAgent` is also safe to invoke concurrently (per-turn state lives in `AgentSession`, not the agent), so
|
||||
the "fresh instance per run" motivation does not apply to it the way it does to a workflow. This is the
|
||||
deliberate asymmetry with `HostedWorkflowState` below: a `Workflow` instance is a stateful run engine that
|
||||
cannot be driven by two runners at once, so the factory/`cacheWorkflow` affordance is load-bearing there
|
||||
for correctness, whereas for agents the container already provides both per-run instances and async setup.
|
||||
- `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an
|
||||
internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint
|
||||
store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has
|
||||
no ordering, so the holder remembers the head checkpoint per session to resume. On subsequent turns it
|
||||
restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python
|
||||
host's restore-then-run semantics), rather than continuing a halted run with no input. When the
|
||||
in-memory cursor misses (new holder / process restart) it reads the session's latest checkpoint from the
|
||||
`CheckpointManager`, so a durable manager resumes across restarts. It accepts either a single workflow
|
||||
instance (which cannot be run by two runners at once, so its turns are processed one at a time) or a
|
||||
workflow factory (`Func<CancellationToken, ValueTask<Workflow>>`). By default the factory builds a fresh
|
||||
instance per run so independent sessions run in parallel; with `cacheWorkflow: true` the factory is invoked
|
||||
once lazily and its result is cached and reused (a deferred, cached target that, like the instance, cannot
|
||||
run concurrent turns). A resume rehydrates an instance from the session's checkpoint in the shared store, so
|
||||
per-run instances still continue the same run; concurrent turns against the same session id remain the
|
||||
application's coordination responsibility.
|
||||
|
||||
### Scope
|
||||
|
||||
Responses only for v1; the facade is named so a parallel `OpenAIChatCompletions` facade can follow.
|
||||
No new package, no OpenAI-SDK-typed reimplementation, no change to `MapOpenAIResponses` public
|
||||
behavior.
|
||||
|
||||
### Security responsibilities
|
||||
|
||||
Consistent with ADR-0027, the application owns the trust boundary. `GetSessionId(...)` returns an
|
||||
untrusted candidate key; the application must authenticate the caller and authorize/bind the id before
|
||||
using it as an `AgentSessionStore` key or workflow checkpoint session id. Multi-user hosts must scope
|
||||
the session store per principal (`IsolationKeyScopedAgentSessionStore`). Helpers stay side-effect-free;
|
||||
persistence happens only after the run completes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Smallest possible surface: the released addition is one facade type plus one thin workflow state
|
||||
holder and one new store method (agents use `AgentSessionStore` directly, no holder).
|
||||
- No duplicated conversion; the app-owned-routing path and the route-owning server share one core.
|
||||
- `MapOpenAIResponses` users are unaffected.
|
||||
|
||||
Negative:
|
||||
|
||||
- The facade's `JsonElement` boundary is less strongly typed than the internal DTOs (accepted to keep
|
||||
the wire model internal and mirror Python's dict boundary).
|
||||
- Workflow resume relies on an in-memory head cursor by default; durable multi-replica hosts must
|
||||
supply their own cursor persistence.
|
||||
|
||||
## More Information
|
||||
|
||||
- Parent ADR: [ADR-0027](0027-hosting-channels.md).
|
||||
- Spec: `docs/specs/003-dotnet-hosting-protocol-helpers.md`.
|
||||
@@ -66,8 +66,11 @@ must be aligned with the helper-first model before implementation. Old vocabular
|
||||
| Package | Import surface | v1 helper-first contents |
|
||||
|---|---|---|
|
||||
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. |
|
||||
| `agent-framework-hosting-a2a` | `agent_framework_hosting_a2a` | A2A `Message` to run conversion and Agent Framework output to A2A `Part` conversion. |
|
||||
| `agent-framework-hosting-mcp` | `agent_framework_hosting_mcp` | Agent and workflow MCP tool adapters, MCP tool arguments to run conversion, and Agent Framework output to MCP `ContentBlock` conversion. |
|
||||
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. |
|
||||
| Future protocol packages | e.g. `agent_framework_hosting_telegram` | Protocol-specific helpers such as `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_session_id(...)`, and command/media helpers when useful. |
|
||||
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | Telegram Bot API helpers: update parsing, chat/session/command/media extraction, final rendering, and streaming edit rendering. |
|
||||
| Future protocol packages | e.g. `agent_framework_hosting_activity_protocol` | Protocol-specific helpers such as `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, and command/media helpers when useful. |
|
||||
|
||||
The core hosting package must not depend on protocol SDKs. Protocol packages may depend on their native protocol SDKs if
|
||||
needed, but helper functions should stay usable from plain app code and tests.
|
||||
@@ -90,6 +93,7 @@ Examples:
|
||||
|
||||
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
|
||||
`responses_session_id(...)`;
|
||||
- `a2a_to_run(...)`, `a2a_from_run(...)`;
|
||||
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
|
||||
`telegram_session_id(...)`, `telegram_command(...)`;
|
||||
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
|
||||
@@ -177,6 +181,9 @@ The target may be:
|
||||
- `await get_target()`;
|
||||
- synchronous `target` only after a target is already available/resolved.
|
||||
|
||||
A workflow instance permits one active run. Concurrent hosts use a factory or
|
||||
builder with `cache_target=False` to resolve a fresh instance per run.
|
||||
|
||||
Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need
|
||||
per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses
|
||||
file-backed cursor storage, the file-based checkpoint storage should share the same app storage root and should be
|
||||
@@ -244,6 +251,146 @@ text deltas, and a completed event. The final completed payload is produced thro
|
||||
also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model
|
||||
metadata.
|
||||
|
||||
## `agent-framework-hosting-a2a`
|
||||
|
||||
The A2A package provides only the conversion seam between the native A2A SDK
|
||||
and Agent Framework:
|
||||
|
||||
- `a2a_to_run(message, *, stream=False) -> AgentRunArgs`
|
||||
- `a2a_from_run(result) -> list[a2a.types.Part]`
|
||||
|
||||
`a2a_to_run(...)` accepts a native A2A `Message` and converts its text, URL,
|
||||
raw-byte, and structured-data parts into one Agent Framework user message.
|
||||
|
||||
`a2a_from_run(...)` accepts an `AgentResponse`, `Message`, or
|
||||
`AgentResponseUpdate` and converts supported text, URI, and data content into
|
||||
native A2A `Part` values. This one helper is usable for both completed and
|
||||
streaming runs.
|
||||
|
||||
The package does not provide an A2A `AgentExecutor`, application, route,
|
||||
request handler, task store, event queue, `TaskUpdater`, task-state policy,
|
||||
artifact-id policy, or session-key policy. Application code composes the two
|
||||
helpers with those native A2A SDK constructs and may use any server framework
|
||||
supported by the SDK.
|
||||
|
||||
## `agent-framework-hosting-mcp`
|
||||
|
||||
The MCP package provides only the conversion seam between native MCP SDK values
|
||||
and Agent Framework:
|
||||
|
||||
- `MCPAgentTool(target, ...)`
|
||||
- `MCPWorkflowTool(target, ...)`
|
||||
- `mcp_to_run(arguments, *, argument_name="task", chat_option_arguments=()) -> AgentRunArgs`
|
||||
- `mcp_from_run(result) -> list[mcp.types.ContentBlock]`
|
||||
|
||||
`MCPAgentTool` represents one Agent Framework agent as one native MCP tool. It
|
||||
derives the default tool name and description from the agent, accepts
|
||||
overrides for those values and the main text parameter, includes app-owned
|
||||
additional parameter schemas, and explicitly maps selected parameter schemas
|
||||
to ChatOptions. Its asynchronous `list_tools()` returns the native `Tool` list,
|
||||
and `call_tool(...)` performs conversion, agent execution, and final result
|
||||
conversion.
|
||||
|
||||
The adapter accepts either an agent or an existing `AgentState`. With a
|
||||
configured `session_id_parameter`, it loads and stores the corresponding
|
||||
`AgentSession`. The application remains responsible for deriving and
|
||||
authorizing the session id and preventing concurrent updates to the same
|
||||
session.
|
||||
|
||||
`MCPWorkflowTool` represents one Agent Framework workflow as one native MCP
|
||||
tool. It derives the tool name and description from the workflow and derives
|
||||
the input schema from the start executor's single declared input type.
|
||||
Object-shaped dataclass and Pydantic inputs become top-level MCP arguments;
|
||||
primitive inputs are wrapped in one configurable argument. The adapter
|
||||
validates the arguments against that type, runs the workflow, and converts
|
||||
terminal outputs to MCP content blocks.
|
||||
|
||||
Workflow instances preserve state and reject concurrent runs. Applications
|
||||
that need independent calls should provide a `WorkflowState` factory with
|
||||
`cache_target=False`. Checkpoint restoration, human-in-the-loop responses, and
|
||||
continuation identifiers remain application-owned contracts. If a workflow
|
||||
stops to request external input, the adapter raises rather than returning an
|
||||
empty successful tool result.
|
||||
|
||||
`mcp_to_run(...)` accepts the argument mapping from a native MCP `call_tool`
|
||||
handler. The application owns the tool schema and may select which required
|
||||
string argument contains the user request. The application should define that
|
||||
argument name once and use the same value in the native tool schema and the
|
||||
`argument_name` parameter so those two sides of the contract remain aligned.
|
||||
Applications may also expose selected ChatOptions fields in their native tool
|
||||
schema and pass those names through `chat_option_arguments`. Only explicitly
|
||||
selected names are copied to run options; the helper does not forward all MCP
|
||||
arguments or own their JSON Schema validation.
|
||||
|
||||
MCP `tools/call` arguments are JSON-only and do not have a native multimodal
|
||||
content-block union. The package does not impose a non-standard JSON
|
||||
representation for multimodal tool arguments.
|
||||
|
||||
`mcp_from_run(...)` accepts an `AgentResponse` or `Message`. It converts text,
|
||||
URI, image data, audio data, and other binary data into native MCP content
|
||||
blocks.
|
||||
|
||||
Its output is specifically the content union accepted by `CallToolResult`.
|
||||
Sampling-only values such as `ToolUseContent` belong to the separate MCP
|
||||
sampling response path and are not emitted by this hosting helper.
|
||||
|
||||
MCP `tools/call` returns one final `CallToolResult`. Streamable HTTP can carry
|
||||
multiple MCP messages and progress notifications can report operation status,
|
||||
but the protocol does not define partial tool-result content chunks.
|
||||
Experimental MCP tasks defer retrieval of the same final result. Therefore the
|
||||
conversion helpers do not expose Agent Framework streaming updates.
|
||||
|
||||
The package does not provide an MCP `Server`, handler registration, transport, route,
|
||||
session policy, authentication, authorization, or deployment wrapper.
|
||||
Application code composes the adapters and conversion helpers with native MCP SDK constructs and
|
||||
may use stdio, streamable HTTP, or another transport supported by the SDK.
|
||||
|
||||
## `agent-framework-hosting-telegram`
|
||||
|
||||
The Telegram package provides side-effect-free helpers around Telegram Bot API
|
||||
update and method payloads. It does not provide a Bot API client, polling loop,
|
||||
webhook route, command registry, retry policy, or rate limiter.
|
||||
|
||||
### Update helpers
|
||||
|
||||
- `telegram_to_run(update, *, resolve_file_url=None, stream=False) -> AgentRunArgs`
|
||||
- `telegram_chat_id(update) -> int | None`
|
||||
- `telegram_session_id(update, *, bot_id) -> str | None`
|
||||
- `telegram_command(update) -> str | None`
|
||||
- `telegram_callback_query_id(update) -> str | None`
|
||||
- `telegram_media_file_id(update_or_message) -> tuple[str, str] | None`
|
||||
|
||||
`telegram_to_run(...)` handles `message`, `edited_message`, and
|
||||
`callback_query` updates. Text and captions become AF text content. When the
|
||||
app supplies an async `resolve_file_url` callback, supported Telegram media
|
||||
file ids can become AF URI content. The package does not call Telegram's
|
||||
`getFile` method itself.
|
||||
|
||||
`telegram_session_id(..., bot_id=...)` includes the bot identity in every key.
|
||||
Private chats return `telegram:<bot_id>:<user_id>`; other chats return
|
||||
`telegram:<bot_id>:<chat_id>`, giving groups a shared session by default. This
|
||||
matches Telegram's native isolation boundaries while preventing two bots from
|
||||
sharing state accidentally. Apps that want per-user sessions inside a group
|
||||
can construct a key that includes both chat and sender ids. The app must
|
||||
authorize those Telegram identities before loading session state.
|
||||
|
||||
`telegram_command(...)` parses Telegram's `/name` and `/name@bot` syntax. It
|
||||
does not register commands or invoke handlers.
|
||||
|
||||
### Response helpers
|
||||
|
||||
- `telegram_from_run(result, *, chat_id, parse_mode=None)`
|
||||
- `telegram_from_streaming_run(stream, *, chat_id, message_id, initial_text=None, parse_mode=None)`
|
||||
|
||||
The helpers produce Telegram method/payload values for app-owned Bot API
|
||||
calls. Final rendering supports text and image URI output and applies
|
||||
Telegram's text-length boundary. Streaming rendering produces cumulative
|
||||
`editMessageText` payloads for a placeholder message id supplied by the app,
|
||||
omitting edits that match an optional `initial_text`, then renders the final
|
||||
rich output. Image-only responses remove the placeholder with `deleteMessage`
|
||||
before sending the image. The app owns the initial placeholder send, Bot API
|
||||
calls, edit throttling, retries, and failure policy.
|
||||
|
||||
## Security responsibilities
|
||||
|
||||
Protocol helper packages parse and render. They do not authenticate callers, authorize access to state, or decide which
|
||||
@@ -346,3 +493,6 @@ Implementation validation must cover:
|
||||
- Responses streaming SSE rendering;
|
||||
- HTTP round-trip tests showing a native FastAPI route using `AgentState` and Responses helpers;
|
||||
- sample type checking for the local Responses sample.
|
||||
- Telegram update parsing, chat/session/command/media extraction, final
|
||||
rendering, and streaming edit rendering;
|
||||
- sample type checking for the local Telegram polling and webhook entry points.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-07-08
|
||||
deciders: rogerbarreto
|
||||
consulted: eavanvalkenburg
|
||||
informed: []
|
||||
---
|
||||
|
||||
# .NET hosting: OpenAI Responses protocol helpers and optional execution state
|
||||
|
||||
Implements [ADR-0032](../decisions/0032-dotnet-hosting-protocol-helpers.md), which realizes the
|
||||
helper-first direction of [ADR-0027](../decisions/0027-hosting-channels.md) for .NET.
|
||||
|
||||
## What is the goal of this feature?
|
||||
|
||||
Let application developers expose an `AIAgent` or workflow over the OpenAI Responses protocol **while
|
||||
owning their own ASP.NET Core route, authentication, middleware, and storage**, by calling small,
|
||||
side-effect-free Agent Framework conversion helpers instead of adopting the batteries-included,
|
||||
route-owning `MapOpenAIResponses` server.
|
||||
|
||||
Success: an application can implement a working `POST /responses` endpoint (sync + streaming) in its
|
||||
own minimal-API handler using only the public helpers plus its own auth/storage, with no dependency on
|
||||
`MapOpenAIResponses` or `IResponsesService`.
|
||||
|
||||
## What is the problem being solved?
|
||||
|
||||
.NET already exposes agents as the OpenAI Responses API, but only through the route-owning
|
||||
`MapOpenAIResponses`/`IResponsesService`, which also owns routing, response/conversation storage,
|
||||
streaming, and lifecycle. An application that wants its own routing (custom auth, middleware, status
|
||||
codes, durable storage, or a different framework surface) currently has no supported way to reuse the
|
||||
framework's Responses<->agent conversion. Every conversion primitive that would make this possible
|
||||
already exists in `Microsoft.Agents.AI.Hosting.OpenAI` but is `internal`.
|
||||
|
||||
This feature un-bundles that conversion into a public, app-callable surface, and adds the minimal
|
||||
execution-state helpers an app needs for session continuity and workflow checkpoint resume.
|
||||
|
||||
## API Changes
|
||||
|
||||
### `Microsoft.Agents.AI.Hosting.OpenAI` (new public static facade `OpenAIResponses`)
|
||||
|
||||
Boundary is `System.Text.Json`; the wire DTOs stay internal. All members are side-effect-free.
|
||||
|
||||
```csharp
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
public static class OpenAIResponses
|
||||
{
|
||||
// Wire -> Agent Framework run input.
|
||||
public static OpenAIResponsesRunRequest ToAgentRunRequest(
|
||||
JsonElement body,
|
||||
OpenAIResponsesMapOptions? mapOptions = null);
|
||||
|
||||
// Agent Framework result -> Responses payload (no originating request required).
|
||||
public static JsonElement WriteResponse(
|
||||
AgentResponse response,
|
||||
string responseId,
|
||||
string? sessionId = null);
|
||||
|
||||
// Agent Framework stream -> Responses SSE `data:` frames.
|
||||
public static IAsyncEnumerable<string> WriteResponseStreamAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
string responseId,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Untrusted candidate continuation key: previous_response_id or conversation id (or null).
|
||||
// Kept SEPARATE from ToAgentRunRequest so using a request-derived key is an explicit decision.
|
||||
public static string? GetSessionId(JsonElement body);
|
||||
|
||||
// Mint a `resp_*` id.
|
||||
public static string CreateResponseId();
|
||||
}
|
||||
|
||||
// Result of ToAgentRunRequest.
|
||||
public sealed class OpenAIResponsesRunRequest
|
||||
{
|
||||
public IList<ChatMessage> Messages { get; }
|
||||
public AgentRunOptions? Options { get; }
|
||||
}
|
||||
```
|
||||
|
||||
`ToAgentRunRequest` honors `OpenAIResponsesMapOptions.RunOptionsFactory` exactly as the route model
|
||||
does (by default no request setting is mapped onto the run; unsupported settings surface as a
|
||||
`NotSupportedException`). `WriteResponse`/`WriteResponseStreamAsync` reuse the existing internal
|
||||
`AgentResponseExtensions.ToResponse` / `AgentResponseUpdateExtensions.ToStreamingResponseAsync`
|
||||
converters (an internal `ToResponse` overload with an optional originating request is added so the
|
||||
facade can render without one). The streaming renderer's existing workflow-event support is preserved.
|
||||
|
||||
### `Microsoft.Agents.AI.Hosting` (execution state, protocol-neutral)
|
||||
|
||||
```csharp
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
// ... existing members ...
|
||||
|
||||
// New: the one missing store operation. Virtual (not abstract) with a default that throws
|
||||
// NotSupportedException, so existing external stores (e.g. the Foundry hosting stores) keep
|
||||
// compiling; the in-box Hosting stores override it. In-box overrides treat deleting a missing
|
||||
// session as a no-op.
|
||||
public virtual ValueTask DeleteSessionAsync(
|
||||
AIAgent agent, string conversationId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// Thin holder: pairs a workflow target with checkpointing + a per-session head cursor.
|
||||
public sealed class HostedWorkflowState
|
||||
{
|
||||
// Shared-instance mode: one instance cannot be run by two runners at once, so turns run one at a time.
|
||||
public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null);
|
||||
|
||||
// Factory mode: by default a fresh instance is built per run, so independent sessions run in parallel.
|
||||
// With cacheWorkflow: true the factory is invoked once lazily and the built instance is cached and reused.
|
||||
public HostedWorkflowState(Func<CancellationToken, ValueTask<Workflow>> workflowFactory, CheckpointManager? checkpointManager = null, bool cacheWorkflow = false);
|
||||
|
||||
// First turn runs forward from the start; subsequent turns restore the session's latest
|
||||
// checkpoint and run forward with the new turn's input, then record the new head checkpoint.
|
||||
public ValueTask<HostedWorkflowRunResult> RunOrResumeAsync(
|
||||
string sessionId, object input, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
For agents, the application uses `AgentSessionStore` directly: `GetSessionAsync(agent, id)` creates a
|
||||
session on miss and returns an independent instance per call (so concurrent calls can fork the same
|
||||
stored state — for example branching from a `previous_response_id` or managing several `conversation`
|
||||
ids side by side — without one branch observing another's in-flight mutations). The store performs no
|
||||
cross-call locking; an application that needs concurrent runs against the same id to be serialized owns
|
||||
that coordination. `SaveSessionAsync(agent, id, session)` persists post-run, including under a newly
|
||||
minted `resp_*` id when the protocol mints a new continuation id. `DeleteSessionAsync` uses the new
|
||||
store method. No agent-side holder is needed: create-on-miss already lives in the store, so a
|
||||
pass-through wrapper would only bind the `agent` argument.
|
||||
|
||||
`HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory
|
||||
`sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but
|
||||
`CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so
|
||||
`RunOrResumeAsync` can resume the correct one. On subsequent turns it restores that checkpoint to
|
||||
rehydrate accumulated workflow state and then runs the workflow forward with the new turn's input,
|
||||
rather than continuing a halted run with no input (which would wait for input
|
||||
indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a `TurnToken` so the
|
||||
turn is driven. When the in-memory cursor misses (a new holder or a process restart), the holder falls
|
||||
back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes
|
||||
correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A
|
||||
resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input).
|
||||
Concurrency depends on how the holder is constructed. With a single shared workflow instance, concurrent runs
|
||||
are not supported, because a workflow instance cannot be run by two runners at once; process turns one at a
|
||||
time. With a workflow factory
|
||||
(`Func<CancellationToken, ValueTask<Workflow>>`) it builds a fresh instance per run by default, so independent
|
||||
sessions run in parallel; a resume rehydrates a fresh instance
|
||||
from the session's checkpoint in the shared store, and concurrent turns against the same session id remain the
|
||||
application's coordination responsibility. Passing `cacheWorkflow: true` instead builds the workflow once,
|
||||
lazily on first use, and reuses it (a deferred, cached target that — like the instance — cannot run concurrent
|
||||
turns). A
|
||||
streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for
|
||||
example to render agent updates over the Responses SSE wire) and records the head checkpoint once the
|
||||
stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep.
|
||||
Because `RunOrResumeAsync`/`RunOrResumeStreamingAsync` are generic over the input type, the application
|
||||
adapts the Responses input into the workflow's start-executor input type at the call site (for example
|
||||
parsing a structured payload into a typed record), without coupling the holder to a specific wire type.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
- ChatCompletions / Conversations helper surfaces (the facade is named so `OpenAIChatCompletions` can
|
||||
follow).
|
||||
- Changing `MapOpenAIResponses` public behavior.
|
||||
- A new package or an OpenAI-SDK-typed reimplementation.
|
||||
- Durable/pluggable workflow checkpoint-cursor storage (in-memory default only for v1).
|
||||
|
||||
## Security responsibilities (application-owned)
|
||||
|
||||
- Authenticate the caller before using any `GetSessionId(...)` result.
|
||||
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
|
||||
`AgentSessionStore` key or a workflow checkpoint session id.
|
||||
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
|
||||
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
|
||||
- Persist session/checkpoint state only after the run or stream has completed.
|
||||
|
||||
## E2E Code Samples
|
||||
|
||||
### Agent over Responses, app-owned route (non-streaming + SSE)
|
||||
|
||||
```csharp
|
||||
var agent = /* an AIAgent */;
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); // in-memory session store
|
||||
|
||||
app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct);
|
||||
JsonElement body = doc.RootElement;
|
||||
|
||||
// App owns auth + id trust decisions.
|
||||
string? candidate = OpenAIResponses.GetSessionId(body);
|
||||
string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId();
|
||||
|
||||
var run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
var session = await sessionStore.GetSessionAsync(agent, sessionId, ct);
|
||||
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
|
||||
if (body.TryGetProperty("stream", out var s) && s.GetBoolean())
|
||||
{
|
||||
http.Response.ContentType = "text/event-stream";
|
||||
var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, ct);
|
||||
await foreach (var frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, sessionId, ct))
|
||||
{
|
||||
await http.Response.WriteAsync(frame, ct);
|
||||
await http.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var result = await agent.RunAsync(run.Messages, session, run.Options, ct);
|
||||
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
|
||||
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId));
|
||||
});
|
||||
```
|
||||
|
||||
### Workflow over Responses with checkpoint resume
|
||||
|
||||
Workflow checkpoint resume requires a **stable** session key across turns. `previous_response_id` changes
|
||||
every turn, so it is not a valid checkpoint key; use the `conversation` id (constant for the conversation).
|
||||
Because `GetSessionId(...)` prefers `previous_response_id`, a workflow route reads the conversation id
|
||||
directly rather than calling `GetSessionId(...)`.
|
||||
|
||||
```csharp
|
||||
var state = new HostedWorkflowState(workflow); // in-memory checkpoints + cursor
|
||||
|
||||
app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct);
|
||||
JsonElement body = doc.RootElement;
|
||||
|
||||
// Stable, authorized checkpoint key. GetConversationId(...) reads the conversation id (string or object).
|
||||
string sessionId = Authorize(http.User, GetConversationId(body))
|
||||
?? OpenAIResponses.CreateResponseId();
|
||||
|
||||
var run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
|
||||
// Runs forward on first call, resumes from the session's head checkpoint thereafter.
|
||||
var result = await state.RunOrResumeAsync(sessionId, run.Messages, ct);
|
||||
|
||||
return Results.Json(OpenAIResponses.WriteResponse(result.AsAgentResponse(),
|
||||
OpenAIResponses.CreateResponseId(), sessionId));
|
||||
});
|
||||
```
|
||||
@@ -11,8 +11,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.31.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.6.0" />
|
||||
<PackageVersion Include="Anthropic" Version="12.35.1" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.7.1" />
|
||||
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.Inference" Version="13.1.0-preview.1.25616.3" />
|
||||
@@ -122,6 +122,7 @@
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Dapr.AI.Microsoft.Extensions" Version="1.18.4" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<Project Path="samples/02-agents/AgentProviders/azure/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/azure/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/custom/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/Agent_With_GitHubCopilot.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/google-gemini/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/ollama/Agent_With_Ollama/Agent_With_Ollama.csproj" />
|
||||
@@ -200,6 +201,7 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
|
||||
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
|
||||
@@ -313,7 +315,19 @@
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/">
|
||||
<Folder Name="/Samples/04-hosting/" />
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/">
|
||||
<File Path="samples/04-hosting/af-hosting/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/local_responses/">
|
||||
<File Path="samples/04-hosting/af-hosting/local_responses/README.md" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses/Server/Server.csproj" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses/Client/Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/local_responses_workflow/">
|
||||
<File Path="samples/04-hosting/af-hosting/local_responses_workflow/README.md" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/" />
|
||||
@@ -639,7 +653,9 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/" />
|
||||
<Folder Name="/Tests/">
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/IntegrationTests/">
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
|
||||
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
|
||||
"src\\Microsoft.Agents.AI.Valkey\\Microsoft.Agents.AI.Valkey.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",
|
||||
|
||||
@@ -427,6 +427,15 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "AgentWithMemory_Step06_MemoryUsingAgentMemory",
|
||||
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_API_KEY", "FOUNDRY_MODEL", "FOUNDRY_EMBEDDING_MODEL", "NEO4J_URI", "NEO4J_USER", "NEO4J_PASSWORD"],
|
||||
SkipReason = "Requires a running Neo4j instance; standalone sample outside the repo's CPM build.",
|
||||
},
|
||||
|
||||
// ── AgentWithRAG ────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
@@ -837,7 +846,7 @@ internal static class AgentsSamples
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step15_DeepResearch",
|
||||
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_BING_CONNECTION_ID"],
|
||||
OptionalEnvironmentVariables = ["AZURE_AI_REASONING_DEPLOYMENT_NAME"],
|
||||
SkipReason = "Requires Azure AI Foundry project with Bing search connection.",
|
||||
SkipReason = "Requires Microsoft Foundry project with Bing search connection.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// Pre-build the solution before running, or pass --build to avoid missing build output failures.
|
||||
//
|
||||
// Required environment variables (for AI-powered verification):
|
||||
// FOUNDRY_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint
|
||||
// FOUNDRY_PROJECT_ENDPOINT — Your Microsoft Foundry project endpoint
|
||||
// FOUNDRY_MODEL — Model deployment name (optional, defaults to gpt-5.4-mini)
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
@@ -130,7 +130,7 @@ internal static class WorkflowSamples
|
||||
ProjectPath = "samples/03-workflows/Agents/FoundryAgent",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
SkipReason = "Requires Azure AI Foundry project endpoint.",
|
||||
SkipReason = "Requires Microsoft Foundry project endpoint.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.13.0</VersionPrefix>
|
||||
<VersionPrefix>1.15.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260703</DateSuffix>
|
||||
<DateSuffix>260722</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.13.0</GitTag>
|
||||
<GitTag>1.15.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -44,6 +44,12 @@ See the README.md for each sample for the prerequisites for that sample.
|
||||
| --- | --- |
|
||||
| [Custom Implementation](./custom/Agent_With_CustomImplementation/) | Create an AIAgent with a custom implementation |
|
||||
|
||||
### [Dapr](./dapr/)
|
||||
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [Agent with Dapr](./dapr/Agent_With_Dapr/) | Create an AIAgent using Dapr's Conversation building block as the inference backend |
|
||||
|
||||
### [Foundry](./foundry/)
|
||||
|
||||
See [foundry/README.md](./foundry/README.md) for the full list of Foundry agent samples,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);DAPR_CONVERSATION</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapr.AI.Microsoft.Extensions" />
|
||||
<!--
|
||||
Dapr.AI.Microsoft.Extensions depends on Microsoft.Extensions.* 10.0.8, which is higher than the
|
||||
versions pinned centrally in Directory.Packages.props. Central transitive pinning is disabled above
|
||||
for this sample and these two direct references are overridden to that minimum. Remove the overrides
|
||||
(and the CentralPackageTransitivePinningEnabled setting) once the central versions are >= 10.0.8.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" VersionOverride="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" VersionOverride="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
apiVersion: dapr.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: ollama
|
||||
spec:
|
||||
type: conversation.ollama
|
||||
metadata:
|
||||
- name: model
|
||||
value: llama3.2
|
||||
- name: cacheTTL
|
||||
value: 10m
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with Dapr as the backend.
|
||||
// Dapr's Conversation building block is used here to route inference to Ollama.
|
||||
|
||||
using Dapr.AI.Conversation.Extensions;
|
||||
using Dapr.AI.Microsoft.Extensions;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
// The Dapr sidecar's gRPC endpoint. This must match the --dapr-grpc-port used when starting
|
||||
// the sidecar (see this sample's README). Override it with the DAPR_GRPC_ENDPOINT environment
|
||||
// variable if you run the sidecar on a different port.
|
||||
var daprGrpcEndpoint = Environment.GetEnvironmentVariable("DAPR_GRPC_ENDPOINT") ?? "http://localhost:3501";
|
||||
|
||||
// Register the Dapr Conversation client with dependency injection.
|
||||
var app = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// Configure the gRPC endpoint for the Dapr sidecar.
|
||||
services.AddDaprConversationClient((_, builder) => builder.UseGrpcEndpoint(daprGrpcEndpoint));
|
||||
// Provide the name of the Conversation component loaded in the sidecar to use.
|
||||
services.AddDaprChatClient(opt => opt.ConversationComponentName = "ollama");
|
||||
}).Build();
|
||||
|
||||
// Get an instance of the Dapr chat client from the dependency injection container.
|
||||
using var scope = app.Services.CreateScope();
|
||||
var daprChatClient = scope.ServiceProvider.GetRequiredService<IChatClient>();
|
||||
|
||||
// Use this chat client to construct an AIAgent.
|
||||
AIAgent agent = daprChatClient.AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate."));
|
||||
@@ -0,0 +1,37 @@
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Docker installed and running on your machine
|
||||
- Ollama installed
|
||||
- Dapr CLI installed ([instructions](https://docs.dapr.io/getting-started/install-dapr-cli/))
|
||||
|
||||
You'll need to download a model from [Ollama's library](https://ollama.com/library) to get started. Open
|
||||
a terminal and run the following, replacing `<model_name>` with the name of the model you want to use from
|
||||
Ollama's library (e.g., `llama3.2`).
|
||||
|
||||
```powershell
|
||||
ollama run <model_name>
|
||||
```
|
||||
|
||||
Once it has downloaded and started running, update the component bundled with this example
|
||||
in `./Components/conversation-ollama.yaml` to reflect the name of the model you just installed, modifying the value of
|
||||
the `model` metadata property, then save your changes and close the file.
|
||||
|
||||
Next, start your Dapr sidecar and tell it where it can look for your components. If launching from this project's directory,
|
||||
run the following; otherwise, replace `./Components` with the path to your components directory.
|
||||
|
||||
```powershell
|
||||
dapr run --app-id agents --resources-path ./Components --dapr-grpc-port 3501
|
||||
```
|
||||
|
||||
The sample connects to the sidecar at `http://localhost:3501` by default. If you start the sidecar on a
|
||||
different gRPC port, set the `DAPR_GRPC_ENDPOINT` environment variable to match before running the sample.
|
||||
|
||||
Because the Dapr sidecar needs to continue running while your application is running, please open another terminal
|
||||
window and run the following command from this project's directory to start the demo.
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -66,6 +66,18 @@ When using multiple providers (e.g., skills + file access), combine their rules
|
||||
})
|
||||
```
|
||||
|
||||
## ⚠️ Security: avoid tool-name collisions
|
||||
|
||||
Built-in auto-approval rules match tool calls **solely by tool name**. A rule cannot tell the
|
||||
provider's own tool apart from any other registered tool that happens to share the same name. If a
|
||||
different tool — especially one with a caller-configurable name, such as the Harness shell tool
|
||||
(`HarnessAgentOptions.ShellToolName`) — is registered under a name that one of these rules approves
|
||||
(e.g. `load_skill`, `read_skill_resource`, `run_skill_script`, or the `file_access_*` names), that
|
||||
tool will be **silently auto-approved**, bypassing the human approval boundary.
|
||||
|
||||
When using auto-approval rules, ensure no other tool's name collides with the reserved names the
|
||||
rules approve, and never assign a configurable tool name that matches one of them.
|
||||
|
||||
## Skills Included
|
||||
|
||||
### unit-converter
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
This project is part of the repo's solution and targets .NET 10 like the rest of the repo, but it
|
||||
intentionally opts out of Central Package Management and source-referencing Microsoft.Agents.AI:
|
||||
it consumes the *published* AgentMemory NuGet packages (which target Microsoft.Agents.AI 1.9.0)
|
||||
instead. Run it with `dotnet run` from this folder.
|
||||
|
||||
ManagePackageVersionsCentrally is off, but dotnet/Directory.Packages.props still unconditionally
|
||||
merges its repo-wide analyzer PackageReference items (no Version, resolved via CPM) into every
|
||||
project that imports it — including this one. With CPM off here those versions can't resolve
|
||||
(NU1015), so each is removed and re-added with an explicit version below (matching
|
||||
AgentWithRAG_Step05_Neo4jGraphRAG, which hits the same issue). xunit.analyzers/Moq.Analyzers are
|
||||
dropped rather than re-added since this project has no test code.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<RootNamespace>AgentMemoryShoppingAssistant</RootNamespace>
|
||||
<!-- OPENAI001: the OpenAIClient(AuthenticationPolicy, options) ctor used for keyless Azure auth is
|
||||
marked experimental in the OpenAI SDK (the MAF Foundry samples use the same pattern). -->
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
|
||||
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
|
||||
<PackageReference Remove="xunit.analyzers" />
|
||||
<PackageReference Remove="Moq.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
|
||||
Microsoft Agent Framework adapter. -->
|
||||
<PackageReference Include="AgentMemory" Version="1.2.0" />
|
||||
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
|
||||
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.17" />
|
||||
<!-- Transitive dependency of Microsoft.Agents.AI; pinned explicitly (CPM is off here) because the
|
||||
version it would otherwise resolve to, 1.12.0, has a known moderate severity vulnerability
|
||||
(GHSA-g94r-2vxg-569j) that fails the repo's NuGet audit (NU1902 as error). Matches the version
|
||||
pinned in dotnet/Directory.Packages.props. -->
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using AgentMemory.Neo4j.Infrastructure;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Neo4j.Driver;
|
||||
|
||||
namespace AgentMemoryShoppingAssistant;
|
||||
|
||||
/// <summary>
|
||||
/// A small retail product graph plus the shopping tools that query it — the .NET counterpart of the
|
||||
/// Python retail-assistant's <c>get_product_tools</c>. Products live in Neo4j as <c>:Product</c> nodes
|
||||
/// linked to <c>:ProductCategory</c> / <c>:ProductBrand</c> nodes, so recommendations and "related
|
||||
/// products" come from graph traversals. Cypher runs through the public <see cref="INeo4jTransactionRunner"/>
|
||||
/// seam. Exposed as <see cref="AIFunction"/>s so a real chat model can call them during a run — the same
|
||||
/// way <c>Neo4jMemoryContextProvider</c> surfaces the memory tools through <c>AIContext.Tools</c> when
|
||||
/// <c>ExposeMemoryToolsFromContextProvider</c> is enabled.
|
||||
/// </summary>
|
||||
public sealed class ProductCatalog(INeo4jTransactionRunner runner)
|
||||
{
|
||||
private readonly INeo4jTransactionRunner _runner = runner;
|
||||
|
||||
private static readonly (string Name, string Category, string Brand, double Price, bool InStock, int Inventory, string Description, int Popularity)[] s_seed =
|
||||
[
|
||||
("Nike Air Zoom Pegasus 40", "shoes", "Nike", 130, true, 40, "Everyday running shoe with responsive cushioning.", 95),
|
||||
("Nike Revolution 7", "shoes", "Nike", 70, true, 60, "Lightweight, budget-friendly running shoe.", 80),
|
||||
("Adidas Ultraboost Light", "shoes", "Adidas", 190, true, 25, "Premium running shoe with Boost cushioning.", 90),
|
||||
("Asics Gel-Kayano 31", "shoes", "Asics", 165, false, 0, "Stability running shoe for overpronation.", 70),
|
||||
("Sony WH-1000XM5", "electronics", "Sony", 350, true, 18, "Industry-leading noise-cancelling headphones.", 92),
|
||||
("Bose QuietComfort Ultra", "electronics", "Bose", 330, true, 12, "Premium noise-cancelling over-ear headphones.", 85),
|
||||
("Apple AirPods Pro 2", "electronics", "Apple", 250, true, 50, "Wireless earbuds with active noise cancellation.", 88),
|
||||
("Garmin Forerunner 265", "electronics", "Garmin", 450, true, 9, "GPS running watch with training metrics.", 78),
|
||||
("Nike Dri-FIT Running Tee", "apparel", "Nike", 35, true, 120, "Breathable, moisture-wicking running shirt.", 65),
|
||||
("Adidas Own the Run Jacket","apparel", "Adidas", 80, true, 33, "Lightweight, water-repellent running jacket.", 60),
|
||||
];
|
||||
|
||||
/// <summary>Seeds the sample product graph (idempotent — safe to run every start).</summary>
|
||||
public Task SeedAsync(CancellationToken ct = default) => this._runner.WriteAsync(async r =>
|
||||
{
|
||||
await r.RunAsync(
|
||||
"""
|
||||
UNWIND $products AS row
|
||||
MERGE (p:Product {name: row.name})
|
||||
SET p.category = row.category, p.brand = row.brand, p.price = row.price,
|
||||
p.in_stock = row.in_stock, p.inventory = row.inventory,
|
||||
p.description = row.description, p.popularity = row.popularity
|
||||
MERGE (c:ProductCategory {name: row.category})
|
||||
MERGE (b:ProductBrand {name: row.brand})
|
||||
MERGE (p)-[:IN_CATEGORY]->(c)
|
||||
MERGE (p)-[:MADE_BY]->(b)
|
||||
""",
|
||||
new
|
||||
{
|
||||
products = s_seed.Select(p => (object)new Dictionary<string, object>
|
||||
{
|
||||
["name"] = p.Name, ["category"] = p.Category, ["brand"] = p.Brand, ["price"] = p.Price,
|
||||
["in_stock"] = p.InStock, ["inventory"] = p.Inventory, ["description"] = p.Description,
|
||||
["popularity"] = p.Popularity,
|
||||
}).ToList(),
|
||||
});
|
||||
}, ct);
|
||||
|
||||
// ── Tools (also usable directly in the scripted demo) ────────────────────────────────────────
|
||||
|
||||
[Description("Search the product catalog for items matching a query, with optional category, brand, and max-price filters.")]
|
||||
public Task<string> SearchProductsAsync(
|
||||
[Description("What the customer is looking for, e.g. 'running shoes'.")] string query,
|
||||
[Description("Optional category filter: shoes, electronics, apparel.")] string? category = null,
|
||||
[Description("Optional brand filter, e.g. 'Nike'.")] string? brand = null,
|
||||
[Description("Optional maximum price.")] double? maxPrice = null,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
const string Cypher =
|
||||
"""
|
||||
MATCH (p:Product)
|
||||
WHERE ANY(w IN split(toLower($query), ' ') WHERE
|
||||
toLower(p.name) CONTAINS w OR toLower(p.description) CONTAINS w OR toLower(p.category) CONTAINS w)
|
||||
AND ($category IS NULL OR p.category = $category)
|
||||
AND ($brand IS NULL OR p.brand = $brand)
|
||||
AND ($maxPrice IS NULL OR p.price <= $maxPrice)
|
||||
RETURN p.name AS name, p.brand AS brand, p.category AS category,
|
||||
p.price AS price, p.in_stock AS inStock
|
||||
ORDER BY p.popularity DESC
|
||||
LIMIT 10
|
||||
""";
|
||||
var cursor = await r.RunAsync(Cypher, new { query, category, brand, maxPrice });
|
||||
return Render("Matches", await cursor.ToListAsync());
|
||||
}, ct);
|
||||
|
||||
[Description("Get personalized product recommendations, optionally biased toward a preferred brand and/or category.")]
|
||||
public Task<string> GetRecommendationsAsync(
|
||||
[Description("The customer's preferred brand (from their saved preferences), if known.")] string? preferredBrand = null,
|
||||
[Description("Optional category to recommend within.")] string? category = null,
|
||||
[Description("How many recommendations to return.")] int limit = 5,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
const string Cypher =
|
||||
"""
|
||||
MATCH (p:Product)
|
||||
WHERE p.in_stock = true
|
||||
AND ($category IS NULL OR p.category = $category)
|
||||
WITH p, (CASE WHEN $preferredBrand IS NOT NULL AND p.brand = $preferredBrand THEN 1 ELSE 0 END) AS onBrand
|
||||
RETURN p.name AS name, p.brand AS brand, p.category AS category, p.price AS price, p.in_stock AS inStock
|
||||
ORDER BY onBrand DESC, p.popularity DESC
|
||||
LIMIT $limit
|
||||
""";
|
||||
var cursor = await r.RunAsync(Cypher, new { preferredBrand, category, limit });
|
||||
var header = preferredBrand is null ? "Recommended for you" : $"Recommended for you (favoring {preferredBrand})";
|
||||
return Render(header, await cursor.ToListAsync());
|
||||
}, ct);
|
||||
|
||||
[Description("Find products related to a given product — same category or same brand — via graph traversal.")]
|
||||
public Task<string> GetRelatedProductsAsync(
|
||||
[Description("The exact product name to find related items for.")] string productName,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
const string Cypher =
|
||||
"""
|
||||
MATCH (p:Product {name: $productName})
|
||||
CALL (p) {
|
||||
MATCH (p)-[:IN_CATEGORY]->(c)<-[:IN_CATEGORY]-(rel:Product) WHERE rel <> p
|
||||
RETURN rel, 'same category' AS reason
|
||||
UNION
|
||||
MATCH (p)-[:MADE_BY]->(b)<-[:MADE_BY]-(rel:Product) WHERE rel <> p
|
||||
RETURN rel, 'same brand' AS reason
|
||||
}
|
||||
WITH rel, collect(DISTINCT reason) AS reasons
|
||||
RETURN rel.name AS name, rel.brand AS brand, rel.category AS category,
|
||||
rel.price AS price, rel.in_stock AS inStock, rel.popularity AS popularity,
|
||||
reduce(s = '', x IN reasons | CASE WHEN s = '' THEN x ELSE s + ', ' + x END) AS reason
|
||||
ORDER BY popularity DESC
|
||||
LIMIT 5
|
||||
""";
|
||||
var cursor = await r.RunAsync(Cypher, new { productName });
|
||||
return Render($"Related to {productName}", await cursor.ToListAsync());
|
||||
}, ct);
|
||||
|
||||
[Description("Check whether a product is in stock and how many units are available.")]
|
||||
public Task<string> CheckInventoryAsync(
|
||||
[Description("The exact product name to check.")] string productName,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
var cursor = await r.RunAsync(
|
||||
"MATCH (p:Product {name: $productName}) RETURN p.name AS name, p.in_stock AS inStock, p.inventory AS inventory",
|
||||
new { productName });
|
||||
var rows = await cursor.ToListAsync();
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return $"'{productName}' was not found in the catalog.";
|
||||
}
|
||||
|
||||
var rec = rows[0];
|
||||
var inStock = rec["inStock"].As<bool>();
|
||||
return inStock
|
||||
? $"{rec["name"].As<string>()}: In stock ({rec["inventory"].As<long>()} available)."
|
||||
: $"{rec["name"].As<string>()}: Out of stock.";
|
||||
}, ct);
|
||||
|
||||
/// <summary>The retail tools as MAF/MEAI <see cref="AIFunction"/>s (attach to the agent's ChatOptions.Tools).</summary>
|
||||
public IReadOnlyList<AIFunction> CreateAIFunctions() =>
|
||||
[
|
||||
AIFunctionFactory.Create(this.SearchProductsAsync, "search_products",
|
||||
"Search the product catalog with optional category/brand/price filters."),
|
||||
AIFunctionFactory.Create(this.GetRecommendationsAsync, "get_recommendations",
|
||||
"Get personalized recommendations, optionally favoring a preferred brand/category."),
|
||||
AIFunctionFactory.Create(this.GetRelatedProductsAsync, "get_related_products",
|
||||
"Find products related to a given product via the graph."),
|
||||
AIFunctionFactory.Create(this.CheckInventoryAsync, "check_inventory",
|
||||
"Check stock/availability for a product."),
|
||||
];
|
||||
|
||||
private static string Render(string header, List<IRecord> rows)
|
||||
{
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return $"{header}: (no matches)";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder().Append(header).Append(':').AppendLine();
|
||||
foreach (var rec in rows)
|
||||
{
|
||||
var stock = rec["inStock"].As<bool>() ? "in stock" : "out of stock";
|
||||
var reason = rec.Keys.Contains("reason") ? $" [{rec["reason"].As<string>()}]" : string.Empty;
|
||||
sb.Append(" • ")
|
||||
.Append(rec["name"].As<string>())
|
||||
.Append(" — ").Append(rec["brand"].As<string>())
|
||||
.Append(", ").Append(rec["category"].As<string>())
|
||||
.Append(", $").Append(rec["price"].As<double>().ToString("0"))
|
||||
.Append(", ").Append(stock).Append(reason)
|
||||
.AppendLine();
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Agent Memory — Shopping Assistant (Microsoft Agent Framework, .NET)
|
||||
//
|
||||
// A .NET port of the Neo4j Labs "agent-memory" retail-assistant example
|
||||
// (https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant,
|
||||
// referenced from https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory).
|
||||
//
|
||||
// A shopping assistant that LEARNS a customer's preferences and RECOMMENDS products via graph
|
||||
// traversal, backed by DURABLE memory in Neo4j. It uses the AgentMemory library — a .NET port of the
|
||||
// Python memory provider, not an officially recognized Neo4j integration — and its Microsoft Agent
|
||||
// Framework adapter:
|
||||
// • Neo4jMemoryContextProvider (an AIContextProvider) — recalls memory before each run, persists
|
||||
// after, and (via ExposeMemoryToolsFromContextProvider) surfaces the memory tools (search/remember/
|
||||
// recall) itself through AIContext.Tools
|
||||
// • ProductCatalog.CreateAIFunctions() — retail tools over a Neo4j :Product graph
|
||||
//
|
||||
// Configuration (environment variables, matching the other Foundry samples):
|
||||
// AZURE_OPENAI_ENDPOINT (required) — your Azure OpenAI / Foundry endpoint
|
||||
// AZURE_OPENAI_API_KEY (optional) — API key; if unset, DefaultAzureCredential (az login) is used
|
||||
// FOUNDRY_MODEL (default: gpt-4o-mini) — chat model deployment
|
||||
// FOUNDRY_EMBEDDING_MODEL (default: text-embedding-3-small) — embedding model deployment (1536 dims)
|
||||
// NEO4J_URI (default: bolt://localhost:7687)
|
||||
// NEO4J_USER (default: neo4j)
|
||||
// NEO4J_PASSWORD (default: password)
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using AgentMemory.Abstractions.Services;
|
||||
using AgentMemory.AgentFramework;
|
||||
using AgentMemory.Core;
|
||||
using AgentMemory.Core.Stubs;
|
||||
using AgentMemory.Neo4j.Infrastructure;
|
||||
using AgentMemoryShoppingAssistant;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI;
|
||||
|
||||
// ── Model + credentials (Azure OpenAI / Foundry, via env vars) ───────────────────────────────────
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
|
||||
var chatModel = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini";
|
||||
var embeddingModel = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL") ?? "text-embedding-3-small";
|
||||
|
||||
var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(endpoint) };
|
||||
// API key if provided, otherwise Azure credential (dev: `az login`).
|
||||
// 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.
|
||||
OpenAIClient openAI = string.IsNullOrWhiteSpace(apiKey)
|
||||
? new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
|
||||
: new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions);
|
||||
|
||||
IChatClient chatClient = openAI.GetChatClient(chatModel).AsIChatClient();
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator =
|
||||
openAI.GetEmbeddingClient(embeddingModel).AsIEmbeddingGenerator();
|
||||
|
||||
// ── AgentMemory (Neo4j) DI ───────────────────────────────────────────────────────────────────────
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
||||
|
||||
builder.Services.AddNeo4jAgentMemory(options =>
|
||||
{
|
||||
options.Uri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? "bolt://localhost:7687";
|
||||
options.Username = Environment.GetEnvironmentVariable("NEO4J_USER") ?? "neo4j";
|
||||
options.Password = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? "password";
|
||||
});
|
||||
builder.Services.AddAgentMemoryCore(_ => { });
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<IIdGenerator, GuidIdGenerator>();
|
||||
builder.Services.TryAddSingleton(chatClient);
|
||||
builder.Services.TryAddSingleton(embeddingGenerator);
|
||||
builder.Services.AddAgentMemoryFramework(options =>
|
||||
{
|
||||
options.AutoExtractOnPersist = true;
|
||||
options.ContextFormat.IncludeEntities = true;
|
||||
options.ContextFormat.IncludeFacts = true;
|
||||
options.ContextFormat.IncludePreferences = true;
|
||||
options.ExposeMemoryToolsFromContextProvider = true;
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
await using var hostDisposal = (IAsyncDisposable)host;
|
||||
|
||||
await using var scope = host.Services.CreateAsyncScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
// ── Setup: schema + sample product graph ─────────────────────────────────────────────────────────
|
||||
var catalog = new ProductCatalog(sp.GetRequiredService<INeo4jTransactionRunner>());
|
||||
await sp.GetRequiredService<ISchemaBootstrapper>().BootstrapAsync();
|
||||
await catalog.SeedAsync();
|
||||
Console.WriteLine("Neo4j schema ready; sample products loaded.\n");
|
||||
|
||||
// ── The shopping assistant: context provider (recall + memory tools) + product tools ─────────────
|
||||
var memoryProvider = sp.GetRequiredService<Neo4jMemoryContextProvider>();
|
||||
var productTools = catalog.CreateAIFunctions();
|
||||
|
||||
// WithMemoryOwnerScoping(sp) scopes the whole invocation (recall, tool calls, persistence) to the
|
||||
// owner set via WithMemoryIdentity below — no manual BeginOwnerScope wrapping needed per turn.
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = chatModel,
|
||||
Instructions =
|
||||
"You are a helpful shopping assistant for an online store. Learn and remember the customer's "
|
||||
+ "preferences (brands, budget, categories) using the memory tools, and recommend products that "
|
||||
+ "fit using the product tools. Explain why each recommendation matches, and suggest alternatives "
|
||||
+ "when something is out of stock.",
|
||||
// memoryProvider appends the six memory tools (search_memory, remember_fact, ...) to this list
|
||||
// on every model call via AIContext.Tools — see ExposeMemoryToolsFromContextProvider above.
|
||||
Tools = [.. productTools],
|
||||
},
|
||||
AIContextProviders = [memoryProvider],
|
||||
}).WithMemoryOwnerScoping(sp);
|
||||
|
||||
const string Shopper = "shopper-amelia";
|
||||
|
||||
// ── Session A — the customer shops; the model calls the tools and remembers preferences ──────────
|
||||
Console.WriteLine(">> Session A\n");
|
||||
var sessionA = (await agent.CreateSessionAsync())
|
||||
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-a", applicationId: "retail-demo");
|
||||
|
||||
foreach (var turn in new[]
|
||||
{
|
||||
"Hi! I'm looking for running shoes. I love Nike and want to stay under $150.",
|
||||
"Nice — what would you recommend for me, and is anything I might like out of stock?",
|
||||
})
|
||||
{
|
||||
await SayAsync(agent, sessionA, turn);
|
||||
}
|
||||
|
||||
// ── Session B — a NEW session for the same shopper still recalls her preferences ─────────────────
|
||||
Console.WriteLine(">> Session B — a brand-new session; memory is durable\n");
|
||||
var sessionB = (await agent.CreateSessionAsync())
|
||||
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-b", applicationId: "retail-demo");
|
||||
|
||||
await SayAsync(agent, sessionB, "I'm back — remind me what I like and suggest something new.");
|
||||
|
||||
Console.WriteLine("=== Done. Preferences + messages persist in Neo4j across sessions. ===");
|
||||
|
||||
// One conversational turn. Owner scoping (recall, tool calls, and persistence) is guaranteed
|
||||
// automatically by the WithMemoryOwnerScoping-wrapped agent — no manual BeginOwnerScope needed here.
|
||||
static async Task SayAsync(AIAgent agent, AgentSession session, string message)
|
||||
{
|
||||
Console.WriteLine($"USER : {message}");
|
||||
var response = await agent.RunAsync(message, session);
|
||||
Console.WriteLine($"ASSISTANT : {response.Text}\n");
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Agent with Memory Using AgentMemory — Shopping Assistant
|
||||
|
||||
A **.NET port of the Neo4j Labs "agent-memory" retail assistant** example
|
||||
([`microsoft_agent_retail_assistant`](https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant),
|
||||
referenced from the [Learn integration page](https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory)).
|
||||
A shopping assistant that **learns a customer's preferences** and **recommends products via graph
|
||||
traversal**, backed by durable memory in Neo4j.
|
||||
|
||||
It uses the [`AgentMemory`](https://www.nuget.org/packages/AgentMemory) library — a .NET port of the
|
||||
(Python-only) Neo4j Labs memory provider, **not an officially recognized Neo4j integration** — through
|
||||
its Microsoft Agent Framework adapter.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- **`Neo4jMemoryContextProvider`** (an `AIContextProvider`) — recalls relevant memory before each run,
|
||||
persists new memory after (the same bidirectional pattern as the official provider), and — via
|
||||
`ExposeMemoryToolsFromContextProvider = true` — surfaces the memory tools (search / remember / recall)
|
||||
itself through `AIContext.Tools`.
|
||||
- **`ProductCatalog.CreateAIFunctions()`** — retail tools over a Neo4j `:Product` graph (search /
|
||||
recommend / related / inventory).
|
||||
- Preference learning that persists across a brand-new `AgentSession` for the same shopper.
|
||||
- Graph-based product recommendations and "related products" via traversal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- A **Neo4j 5.x** instance (the sample bootstraps the schema and seeds sample products)
|
||||
- An **Azure OpenAI / Foundry** deployment (a chat model + an embedding model)
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Required | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `AZURE_OPENAI_ENDPOINT` | ✅ | — | Azure OpenAI / Foundry endpoint |
|
||||
| `AZURE_OPENAI_API_KEY` | — | — | API key; if unset, `DefaultAzureCredential` (`az login`) is used |
|
||||
| `FOUNDRY_MODEL` | — | `gpt-4o-mini` | chat model deployment |
|
||||
| `FOUNDRY_EMBEDDING_MODEL` | — | `text-embedding-3-small` | embedding model deployment (1536 dims) |
|
||||
| `NEO4J_URI` | — | `bolt://localhost:7687` | Neo4j bolt URI |
|
||||
| `NEO4J_USER` | — | `neo4j` | Neo4j user |
|
||||
| `NEO4J_PASSWORD` | — | `password` | Neo4j password |
|
||||
|
||||
> Ensure the embedding model's dimensions match the Neo4j vector-index dimensions AgentMemory bootstraps
|
||||
> (default 1536, which matches `text-embedding-3-small`).
|
||||
|
||||
## Run the Sample
|
||||
|
||||
```bash
|
||||
docker run -d --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5.26
|
||||
|
||||
export AZURE_OPENAI_ENDPOINT="https://<your-resource>.openai.azure.com"
|
||||
export AZURE_OPENAI_API_KEY="<your-key>" # or omit and `az login`
|
||||
export FOUNDRY_MODEL="gpt-4o-mini"
|
||||
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
1. The sample bootstraps the Neo4j schema and seeds a small product graph (`:Product`,
|
||||
`:ProductCategory`, `:ProductBrand` nodes).
|
||||
2. **Session A** — the shopper says she wants running shoes, loves Nike, and has a $150 budget; the
|
||||
agent calls the memory tools to remember this and the product tools to recommend matching items.
|
||||
3. **Session B** — a brand-new session for the same shopper (`shopper-amelia`) still recalls her
|
||||
preferences and can suggest something new, because memory persists in Neo4j across sessions.
|
||||
|
||||
## Note on packaging
|
||||
|
||||
This sample is part of the repo's solution and targets .NET 10 like every other sample, but it
|
||||
deliberately opts out of **Central Package Management** and does **not** reference `Microsoft.Agents.AI`
|
||||
via the repo's in-source project — it consumes the **published** `AgentMemory` NuGet packages instead
|
||||
(which target `Microsoft.Agents.AI` 1.9.0). A version that references the repo's current
|
||||
`Microsoft.Agents.AI` source would require AgentMemory to be rebuilt against that version first.
|
||||
@@ -9,6 +9,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|
||||
|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ AIProjectClient aiProjectClient = new(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Create an In-Memory vector store that uses the Azure AI Foundry embedding model to generate embeddings.
|
||||
// Create an In-Memory vector store that uses the Microsoft Foundry embedding model to generate embeddings.
|
||||
VectorStore vectorStore = new InMemoryVectorStore(new()
|
||||
{
|
||||
EmbeddingGenerator = aiProjectClient.GetProjectOpenAIClient().GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ AIProjectClient aiProjectClient = new(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Create a Qdrant vector store that uses the Azure AI Foundry embedding model to generate embeddings.
|
||||
// Create a Qdrant vector store that uses the Microsoft Foundry embedding model to generate embeddings.
|
||||
QdrantClient client = new("localhost");
|
||||
VectorStore vectorStore = new QdrantVectorStore(client, ownsClient: true, new()
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Structured Output — Configure agents to return typed JSON
|
||||
//
|
||||
// This sample shows how to configure a ChatClientAgent to produce
|
||||
// structured output using JSON schema constraints with Azure AI Foundry.
|
||||
// structured output using JSON schema constraints with Microsoft Foundry.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Agent Observability — OpenTelemetry tracing with Azure AI Foundry
|
||||
// Agent Observability — OpenTelemetry tracing with Microsoft Foundry
|
||||
//
|
||||
// This sample shows how to instrument an AI agent with OpenTelemetry
|
||||
// for distributed tracing and telemetry logging.
|
||||
|
||||
@@ -19,7 +19,7 @@ var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt
|
||||
// Create a host builder that we will register services with and then run.
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// Create the AI agent from the Azure AI Foundry project client.
|
||||
// Create the AI agent from the Microsoft Foundry project client.
|
||||
// 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// Middleware — Chain multiple middleware layers on an agent
|
||||
//
|
||||
// This sample shows multiple middleware layers working together with Azure AI Foundry:
|
||||
// This sample shows multiple middleware layers working together with Microsoft Foundry:
|
||||
// chat client (global/per-request), agent run (PII filtering and guardrails),
|
||||
// function invocation (logging and result overrides), human-in-the-loop
|
||||
// approval workflows for sensitive function calls, and MessageAIContextProvider
|
||||
@@ -15,7 +15,7 @@ using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Get Azure AI Foundry configuration from environment variables
|
||||
// Get Microsoft Foundry configuration from environment variables
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Background Responses — Asynchronous agent execution with polling
|
||||
//
|
||||
// This sample shows how to use background responses with ChatClientAgent
|
||||
// and Azure AI Foundry for non-blocking agent execution.
|
||||
// and Microsoft Foundry for non-blocking agent execution.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
|
||||
@@ -67,7 +67,7 @@ static string GetTime([Description("The city name.")] string city) =>
|
||||
// asking for alternative destinations. The model will process this injected message on the next
|
||||
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
|
||||
[Description("Check current travel advisories for a city.")]
|
||||
static string CheckTravelAdvisory([Description("The city name.")] string city)
|
||||
static async Task<string> CheckTravelAdvisory([Description("The city name.")] string city)
|
||||
{
|
||||
// Simulated travel advisory data.
|
||||
var advisory = city.ToUpperInvariant() switch
|
||||
@@ -85,9 +85,13 @@ static string CheckTravelAdvisory([Description("The city name.")] string city)
|
||||
// When an advisory is found, inject a follow-up question so the model automatically
|
||||
// suggests alternatives without the user needing to ask.
|
||||
var runContext = AIAgent.CurrentRunContext!;
|
||||
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
|
||||
runContext.Session!,
|
||||
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
|
||||
var injector = runContext.Agent.GetService<MessageInjectingChatClient>();
|
||||
if (injector is not null)
|
||||
{
|
||||
await injector.EnqueueMessagesAsync(
|
||||
runContext.Session!,
|
||||
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
|
||||
}
|
||||
|
||||
return advisory;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the required role to invoke models in the Foundry project.
|
||||
|
||||
**Note**: These samples use models hosted through Microsoft Foundry. For more information, see [Azure AI Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/).
|
||||
**Note**: These samples use models hosted through Microsoft Foundry. For more information, see [Microsoft Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/).
|
||||
|
||||
**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Foundry project. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ The simplest agent evaluation: create a Foundry agent, run it against test quest
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`)
|
||||
- A deployed model in your Azure AI Foundry project
|
||||
- A deployed model in your Microsoft Foundry project
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
|
||||
+6
@@ -14,6 +14,12 @@ It builds on Post 1's personal finance assistant and teaches it to work with *yo
|
||||
saving and deleting still pause for approval. The `place_trade` tool is also wrapped in an
|
||||
`ApprovalRequiredAIFunction` (see `TradingTools.cs`), so the harness surfaces an approval prompt
|
||||
before any trade runs. The trade itself is simulated — no real order is placed.
|
||||
|
||||
> ⚠️ **Security — avoid tool-name collisions:** auto-approval rules such as
|
||||
> `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` match tool calls **solely by tool name**. Any
|
||||
> other registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
|
||||
> `file_access_grep`) would be silently auto-approved, bypassing the human
|
||||
> approval boundary. Ensure no other tool's name collides with the reserved names a rule approves.
|
||||
- **Durable memory, two ways:**
|
||||
- **File memory** (coarse-grained, explicit) — the agent reads/writes files such as
|
||||
`watchlist.md`. File memory is on by default; its files live on disk under
|
||||
|
||||
+7
-5
@@ -133,7 +133,7 @@ AIAgent researchAgent = ResearchAgent.Create(chatClient);
|
||||
// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors
|
||||
// every command to the vault, and the deny-list policy pre-filters obviously destructive commands.
|
||||
// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.)
|
||||
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
await using var shellExecutor = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
{
|
||||
WorkingDirectory = vaultDir,
|
||||
ConfineWorkingDirectory = true,
|
||||
@@ -160,7 +160,9 @@ using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptio
|
||||
// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the
|
||||
// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and
|
||||
// CodeAct.
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
|
||||
// The shell is wired up in two parts: the ShellEnvironmentProvider injects OS/shell/CWD info into the
|
||||
// system prompt, and the shell tool is registered below in ChatOptions.
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct, new ShellEnvironmentProvider(shellExecutor)];
|
||||
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
@@ -170,8 +172,6 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
DisableAgentSkillsProvider = true,
|
||||
// Fan-out research is delegated to this background agent.
|
||||
BackgroundAgents = [researchAgent],
|
||||
// The confined shell, exposed as the approval-gated run_shell tool.
|
||||
ShellExecutor = shell,
|
||||
// Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt.
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
@@ -179,7 +179,7 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
},
|
||||
// Start in "execute" mode for quick lookups and actions; switch any time with /mode plan.
|
||||
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
|
||||
// Our skills provider plus CodeAct.
|
||||
// Our skills provider, CodeAct, and the shell environment provider.
|
||||
AIContextProviders = contextProviders,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
@@ -188,6 +188,8 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
[
|
||||
StockTools.CreateGetStockPriceTool(),
|
||||
TradingTools.CreatePlaceTradeTool(),
|
||||
// The confined shell, exposed as the approval-gated run_shell tool.
|
||||
shellExecutor.AsAIFunction(requireApproval: true),
|
||||
],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ public sealed class ModeCommandHandler : CommandHandler
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
string current = this._modeProvider.GetMode(session);
|
||||
string current = await this._modeProvider.GetModeAsync(session).ConfigureAwait(false);
|
||||
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ public sealed class ModeCommandHandler : CommandHandler
|
||||
|
||||
try
|
||||
{
|
||||
this._modeProvider.SetMode(session, newMode);
|
||||
await this._modeProvider.SetModeAsync(session, newMode).ConfigureAwait(false);
|
||||
ux.CurrentMode = newMode;
|
||||
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
|
||||
{
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -111,16 +111,15 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
|
||||
/// by the agent on its next opportunity.
|
||||
/// </summary>
|
||||
internal Task OnStreamingInputAsync(string text)
|
||||
internal async Task OnStreamingInputAsync(string text)
|
||||
{
|
||||
if (this._messageInjector is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]);
|
||||
this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session));
|
||||
return Task.CompletedTask;
|
||||
await this._messageInjector.EnqueueMessagesAsync(this._session, [new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false);
|
||||
this._ux.SetQueuedMessages(await this._messageInjector.GetPendingMessagesAsync(this._session).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -136,7 +135,7 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
this.CompleteTurn();
|
||||
await this.CompleteTurnAsync().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,17 +150,19 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = messages;
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? [];
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector is not null
|
||||
? await this._messageInjector.GetPendingMessagesAsync(this._session).ConfigureAwait(false)
|
||||
: [];
|
||||
|
||||
while (nextMessages is not null)
|
||||
{
|
||||
var runOptions = new AgentRunOptions();
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
|
||||
await observer.ConfigureRunOptionsAsync(runOptions, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
|
||||
this._ux.BeginStreaming();
|
||||
this._ux.BeginStreamingOutput();
|
||||
|
||||
@@ -171,7 +172,7 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
if (this._modeProvider is not null)
|
||||
{
|
||||
string currentMode = this._modeProvider.GetMode(this._session);
|
||||
string currentMode = await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
|
||||
if (currentMode != this._ux.CurrentMode)
|
||||
{
|
||||
this._ux.CurrentMode = currentMode;
|
||||
@@ -199,7 +200,7 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
|
||||
lastPendingMessages = await this.SyncQueuedMessageDisplayAsync(lastPendingMessages).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -208,7 +209,7 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
}
|
||||
|
||||
// Final sync after streaming.
|
||||
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
|
||||
lastPendingMessages = await this.SyncQueuedMessageDisplayAsync(lastPendingMessages).ConfigureAwait(false);
|
||||
|
||||
this._ux.StopSpinner();
|
||||
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
|
||||
@@ -261,13 +262,13 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
nextMessages = drained.Count > 0 ? [.. drained] : null;
|
||||
}
|
||||
|
||||
this.CompleteTurn();
|
||||
await this.CompleteTurnAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void CompleteTurn()
|
||||
private async Task CompleteTurnAsync()
|
||||
{
|
||||
this._ux.EndStreaming();
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -275,14 +276,15 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
/// Messages that have been consumed (drained by the service) are echoed to the output
|
||||
/// area as regular user-input entries.
|
||||
/// </summary>
|
||||
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
|
||||
/// <returns>The updated snapshot of pending messages.</returns>
|
||||
private async Task<IReadOnlyList<ChatMessage>> SyncQueuedMessageDisplayAsync(IReadOnlyList<ChatMessage> lastPendingMessages)
|
||||
{
|
||||
if (this._messageInjector is null)
|
||||
{
|
||||
return;
|
||||
return lastPendingMessages;
|
||||
}
|
||||
|
||||
var pending = this._messageInjector.GetPendingMessages(this._session);
|
||||
var pending = await this._messageInjector.GetPendingMessagesAsync(this._session).ConfigureAwait(false);
|
||||
|
||||
int consumedCount = lastPendingMessages.Count - pending.Count;
|
||||
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
|
||||
@@ -291,7 +293,7 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
this._ux.WriteUserInputEcho(text);
|
||||
}
|
||||
|
||||
lastPendingMessages = pending;
|
||||
this._ux.SetQueuedMessages(pending);
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ public static class HarnessConsole
|
||||
? await options.SessionFactory(agent)
|
||||
: await agent.CreateSessionAsync();
|
||||
|
||||
string? initialMode = modeProvider is null ? null : await modeProvider.GetModeAsync(session);
|
||||
|
||||
using var component = new HarnessAppComponent(
|
||||
placeholder: userPrompt,
|
||||
initialMode: modeProvider?.GetMode(session),
|
||||
initialMode: initialMode,
|
||||
inputEnabled: messageInjector is not null,
|
||||
runnerFactory: ux => new HarnessAgentRunner(
|
||||
agent: agent,
|
||||
|
||||
+1
-3
@@ -20,9 +20,7 @@ public abstract class ConsoleObserver
|
||||
/// <param name="options">The run options to configure.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
}
|
||||
public virtual ValueTask ConfigureRunOptionsAsync(AgentRunOptions options, AIAgent agent, AgentSession session) => default;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
|
||||
|
||||
+3
-3
@@ -40,9 +40,9 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
public override async ValueTask ConfigureRunOptionsAsync(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
|
||||
if (this.IsPlanningMode(await this._modeProvider.GetModeAsync(session).ConfigureAwait(false)))
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
}
|
||||
@@ -205,7 +205,7 @@ public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
|
||||
if (selection == ApproveOption)
|
||||
{
|
||||
this._modeProvider.SetMode(session, this._executionModeName);
|
||||
await this._modeProvider.SetModeAsync(session, this._executionModeName).ConfigureAwait(false);
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"✅ Switched to {this._executionModeName} mode.",
|
||||
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
|
||||
|
||||
@@ -85,7 +85,6 @@ AIAgent agent =
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
Name = "ResearchAgent",
|
||||
Description = "A research assistant that plans and executes research tasks.",
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
|
||||
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
|
||||
Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
|
||||
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Microsoft Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
@@ -19,7 +19,7 @@ Key features showcased:
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
1. A Microsoft Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
@@ -27,7 +27,7 @@ Before running this sample, ensure you have:
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry OpenAI endpoint
|
||||
# Required: Your Microsoft Foundry OpenAI endpoint
|
||||
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
|
||||
-2
@@ -57,7 +57,6 @@ AIAgent webSearchAgent =
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
@@ -107,7 +106,6 @@ AIAgent parentAgent =
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
|
||||
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
|
||||
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
|
||||
DisableWebSearch = true,
|
||||
BackgroundAgents = [webSearchAgent],
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ A parent agent receives a list of stock tickers and uses a web-search background
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Azure AI Foundry endpoint with an OpenAI model deployment
|
||||
- A Microsoft Foundry endpoint with an OpenAI model deployment
|
||||
- Set the following environment variables:
|
||||
- `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL
|
||||
- `FOUNDRY_MODEL` — Model deployment name (defaults to `gpt-5.4`)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider
|
||||
// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider
|
||||
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
|
||||
// and extract information from the data, then write results back as new files.
|
||||
//
|
||||
// The sample includes a pre-populated `working/` folder with sales transaction data.
|
||||
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
|
||||
// which matches this sample's folder layout.
|
||||
// File access is opt-in: setting HarnessAgentOptions.FileAccessStore enables the
|
||||
// FileAccessProvider, and this sample points it at the `working/` folder below the location of the executable.
|
||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||
//
|
||||
// Special commands:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
|
||||
This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, a `FileAccessStore`, and opt out of unused features.
|
||||
|
||||
Key features showcased:
|
||||
|
||||
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
|
||||
- **FileAccessProvider** — the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
|
||||
- **FileAccessProvider** — file access is opt-in; setting `HarnessAgentOptions.FileAccessStore` to the sample's `working/` folder enables the provider's read/write tools
|
||||
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
|
||||
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
|
||||
- **Streaming output** — responses are streamed token-by-token for a natural experience
|
||||
@@ -15,7 +15,7 @@ Key features showcased:
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
1. A Microsoft Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
@@ -23,7 +23,7 @@ Before running this sample, ensure you have:
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry OpenAI endpoint
|
||||
# Required: Your Microsoft Foundry OpenAI endpoint
|
||||
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
@@ -51,6 +51,15 @@ You can ask the agent to:
|
||||
|
||||
E.g. try the following prompt `Please process the sales.csv file by first filtering it to only North region sales, and then calculating the sum of sales by person. I'd like to write the results of the processing to north_region_totals.csv`.
|
||||
|
||||
## ⚠️ Security: avoid tool-name collisions
|
||||
|
||||
This sample uses `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` to auto-approve read-only file
|
||||
access tools. Built-in auto-approval rules match tool calls **solely by tool name**, so any other
|
||||
registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
|
||||
`file_access_grep`) would be **silently auto-approved**, bypassing the
|
||||
human approval boundary. Ensure no other tool's name collides with the reserved names an
|
||||
auto-approval rule approves.
|
||||
|
||||
## Sample Data
|
||||
|
||||
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
|
||||
|
||||
@@ -82,8 +82,9 @@ var instructions =
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
|
||||
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
|
||||
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
|
||||
// TodoProvider, AgentModeProvider, FileMemory, ToolApproval, WebSearch, and
|
||||
// AgentSkillsProvider are on by default. File access is opt-in, so it is enabled here by
|
||||
// supplying a FileAccessStore.
|
||||
AIAgent agent =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
@@ -101,6 +102,8 @@ AIAgent agent =
|
||||
OpenTelemetrySourceName = TracingSourceName,
|
||||
// Point the file memory at a local folder for persistent memory across sessions.
|
||||
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
|
||||
// Enable file access (opt-in) by rooting the file access tools at a local working folder.
|
||||
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
|
||||
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
|
||||
AIContextProviders = [codeAct],
|
||||
ChatOptions = new ChatOptions
|
||||
|
||||
@@ -10,14 +10,14 @@ The agent can plan tasks, manage modes, store memories, read/write files, search
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK
|
||||
- An Azure AI Foundry project endpoint
|
||||
- A Microsoft Foundry project endpoint
|
||||
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Your Microsoft Foundry project endpoint |
|
||||
| `FOUNDRY_MODEL` | Model deployment name (default: `gpt-5.4`) |
|
||||
|
||||
## Running
|
||||
|
||||
@@ -174,9 +174,9 @@ async Task ApprovalLoopAsync()
|
||||
{
|
||||
AutoApprovalRules =
|
||||
[
|
||||
functionCall =>
|
||||
context =>
|
||||
{
|
||||
Console.WriteLine($" Auto-approving: {functionCall.Name}");
|
||||
Console.WriteLine($" Auto-approving: {context.FunctionCallContent.Name}");
|
||||
return ValueTask.FromResult(true);
|
||||
},
|
||||
],
|
||||
@@ -252,7 +252,6 @@ AIAgent CreateLeanHarnessAgent(
|
||||
DisableAgentModeProvider = true,
|
||||
DisableTodoProvider = disableTodoProvider,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
ToolApprovalAgentOptions = toolApprovalAgentOptions,
|
||||
ChatOptions = new ChatOptions
|
||||
|
||||
@@ -32,7 +32,7 @@ The Python sample in [microsoft/agent-framework#6174](https://github.com/microso
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
1. A Microsoft Foundry project with a deployed model (e.g., `gpt-5.4`)
|
||||
2. Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
## Environment Variables
|
||||
@@ -40,7 +40,7 @@ Before running this sample, ensure you have:
|
||||
Set the following environment variables:
|
||||
|
||||
```bash
|
||||
# Required: Your Azure AI Foundry project endpoint
|
||||
# Required: Your Microsoft Foundry project endpoint
|
||||
export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
|
||||
# Optional: Model deployment name (defaults to gpt-5.4)
|
||||
|
||||
@@ -31,7 +31,7 @@ public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -92,7 +92,7 @@ public static class Program
|
||||
string model)
|
||||
{
|
||||
ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync(
|
||||
$"{targetLanguage} Translator",
|
||||
$"{targetLanguage}Translator",
|
||||
new ProjectsAgentVersionCreationOptions(
|
||||
new DeclarativeAgentDefinition(model: model)
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -34,7 +34,7 @@ public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -37,7 +37,7 @@ public static class Program
|
||||
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -70,7 +70,7 @@ public static class Program
|
||||
|
||||
using var traceProvider = traceProviderBuilder.Build();
|
||||
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace WorkflowMagenticOrchestrationSample;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - An Azure AI Foundry project endpoint and model deployment must be configured.
|
||||
/// - A Microsoft Foundry project endpoint and model deployment must be configured.
|
||||
/// - Run <c>az login</c> before executing the sample.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
|
||||
@@ -15,7 +15,7 @@ This sample showcases the Magentic Orchestration Pattern in .NET, setting up a t
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `FOUNDRY_PROJECT_ENDPOINT` set to your Azure AI Foundry project endpoint
|
||||
- `FOUNDRY_PROJECT_ENDPOINT` set to your Microsoft Foundry project endpoint
|
||||
- `FOUNDRY_MODEL` set to your model deployment name (defaults to `gpt-5.4-mini`)
|
||||
- `az login` completed before running the sample
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ namespace WorkflowAgentsInWorkflowsSample;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - An Azure AI Foundry project endpoint and model must be configured.
|
||||
/// - A Microsoft Foundry project endpoint and model must be configured.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -16,13 +16,13 @@ namespace WorkflowAgentsInWorkflowsSample;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - An Azure AI Foundry project endpoint and model must be configured.
|
||||
/// - A Microsoft Foundry project endpoint and model must be configured.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up the Azure AI Foundry client.
|
||||
// Set up the Microsoft Foundry client.
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ namespace MixedWorkflowWithAgentsAndExecutors;
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - Previous foundational samples should be completed first.
|
||||
/// - An Azure AI Foundry project endpoint and model must be configured.
|
||||
/// - A Microsoft Foundry project endpoint and model must be configured.
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
@@ -40,7 +40,7 @@ public static class Program
|
||||
{
|
||||
Console.WriteLine("\n=== Mixed Workflow: Agents and Executors ===\n");
|
||||
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class Program
|
||||
Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n");
|
||||
Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n");
|
||||
|
||||
// Set up the Azure AI Foundry client
|
||||
// Set up the Microsoft Foundry client
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ internal static class Pages
|
||||
</div>
|
||||
<div id="chat"></div>
|
||||
<form id="form">
|
||||
<input id="input" placeholder="Try: 'What time is it?' or 'Search docs for Azure AI Foundry'" autocomplete="off" autofocus />
|
||||
<input id="input" placeholder="Try: 'What time is it?' or 'Search docs for Microsoft Foundry'" autocomplete="off" autofocus />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Framework hosting samples (bring your own route)
|
||||
|
||||
These samples show how to expose an Agent Framework agent or workflow over the OpenAI Responses HTTP
|
||||
protocol from an ASP.NET Core app that you write, where your app owns the HTTP route, authentication, and
|
||||
where conversations are stored.
|
||||
|
||||
## Two ways to expose an agent over the Responses protocol
|
||||
|
||||
Agent Framework gives you two options:
|
||||
|
||||
1. **`MapOpenAIResponses` (batteries included).** A single call maps a ready-made `/responses` endpoint that
|
||||
handles the protocol, routing, and session storage for you. Pick this when you want a working endpoint
|
||||
quickly and the built-in behavior fits. See [AgentWebChat](../../05-end-to-end/AgentWebChat) for a sample
|
||||
that uses it.
|
||||
|
||||
2. **Call the conversion helpers from your own route (these samples).** You write the ASP.NET Core route and
|
||||
call the `OpenAIResponses` helper methods to translate between the Responses HTTP payloads and the agent.
|
||||
The framework only does the protocol translation, so you keep full control of routing, authentication,
|
||||
and where conversations are stored. Pick this when you need hosting behavior the built-in endpoint does
|
||||
not provide.
|
||||
|
||||
## Samples
|
||||
|
||||
| Sample | What it shows |
|
||||
|---|---|
|
||||
| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `AgentSessionStore` for conversation continuity. The simplest sample to start with. |
|
||||
| [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. |
|
||||
|
||||
Each sample is a **client/server pair** split into two projects:
|
||||
|
||||
```
|
||||
local_responses/
|
||||
├── Server/ # exposes POST /responses using the OpenAIResponses helper methods
|
||||
└── Client/ # consumes it two ways: a chat client and an agent
|
||||
```
|
||||
|
||||
The `Client` shows the two ways to consume the endpoint from .NET, both against the same server:
|
||||
|
||||
- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
|
||||
- A Microsoft Agent Framework `AIAgent` (the higher-level agent path).
|
||||
|
||||
## Relationship to `../FoundryHostedAgents/`
|
||||
|
||||
The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that
|
||||
run inside the Foundry Hosted Agents platform, which hosts the agent and exposes the protocol for you. Use
|
||||
those when you want the Foundry-managed hosting surface; use these when you want to host the agent in your
|
||||
own ASP.NET Core app.
|
||||
|
||||
| Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` |
|
||||
|---|---|---|
|
||||
| Server stack | An ASP.NET Core app you write plus the hosting protocol helpers | Foundry Hosted Agents runtime |
|
||||
| Who exposes the route | Your app | The platform |
|
||||
| When to pick this | You need custom hosting code | You want the Foundry-managed hosting surface |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user