Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aed547907 | |||
| 1354c43d1f |
@@ -1,112 +0,0 @@
|
||||
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"
|
||||
@@ -1,133 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,38 +1,25 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue or pull request author and check their team membership.
|
||||
* Resolve the issue 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 or pull request number to resolve author for
|
||||
* @param {string|number} opts.issueNumber - Issue 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 ??
|
||||
context.payload.pull_request?.user?.login;
|
||||
|
||||
let author = context.payload.issue?.user?.login;
|
||||
if (!author) {
|
||||
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;
|
||||
}
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(issueNumber),
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Enforce Python package coverage according to package lifecycle."""
|
||||
|
||||
# ruff:file-ignore[print]
|
||||
# ruff:file-ignore[implicit-namespace-package]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET # ruff:ignore[suspicious-xml-etree-import]
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
DEVELOPMENT_STATUS_PREFIX = "Development Status :: "
|
||||
ENFORCED_DEVELOPMENT_STATUS = 4
|
||||
EXEMPT_PACKAGES = {"devui", "lab"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackagePolicy:
|
||||
"""Coverage policy derived from a package's project metadata."""
|
||||
|
||||
directory: str
|
||||
distribution_name: str
|
||||
development_status: int
|
||||
development_status_label: str
|
||||
enforced: bool
|
||||
exempt: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoverageStats:
|
||||
"""Line and branch coverage counters."""
|
||||
|
||||
lines_valid: int = 0
|
||||
lines_covered: int = 0
|
||||
branches_valid: int = 0
|
||||
branches_covered: int = 0
|
||||
|
||||
@property
|
||||
def line_coverage_percent(self) -> float:
|
||||
"""Return line coverage as a percentage."""
|
||||
if not self.lines_valid:
|
||||
return 0
|
||||
return self.lines_covered / self.lines_valid * 100
|
||||
|
||||
|
||||
def normalize_coverage_path(path: str) -> str:
|
||||
"""Normalize a coverage path for matching."""
|
||||
return path.replace("\\", "/").lstrip("./")
|
||||
|
||||
|
||||
def load_package_policies(packages_dir: Path) -> list[PackagePolicy]:
|
||||
"""Load lifecycle-based coverage policies from package pyproject files."""
|
||||
policies: list[PackagePolicy] = []
|
||||
for pyproject_path in sorted(packages_dir.glob("*/pyproject.toml")):
|
||||
with pyproject_path.open("rb") as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
|
||||
project = pyproject.get("project", {})
|
||||
distribution_name = str(project.get("name", "")).strip()
|
||||
if not distribution_name:
|
||||
raise ValueError(f"{pyproject_path}: project.name is required")
|
||||
|
||||
status_classifiers = [
|
||||
classifier
|
||||
for classifier in project.get("classifiers", [])
|
||||
if classifier.startswith(DEVELOPMENT_STATUS_PREFIX)
|
||||
]
|
||||
if len(status_classifiers) != 1:
|
||||
raise ValueError(
|
||||
f"{pyproject_path}: expected exactly one Development Status classifier, found {len(status_classifiers)}"
|
||||
)
|
||||
|
||||
match = re.fullmatch(r"Development Status :: (\d+) - (.+)", status_classifiers[0])
|
||||
if match is None:
|
||||
raise ValueError(f"{pyproject_path}: malformed Development Status classifier")
|
||||
|
||||
directory = pyproject_path.parent.name
|
||||
development_status = int(match.group(1))
|
||||
exempt = directory in EXEMPT_PACKAGES
|
||||
policies.append(
|
||||
PackagePolicy(
|
||||
directory=directory,
|
||||
distribution_name=distribution_name,
|
||||
development_status=development_status,
|
||||
development_status_label=match.group(2),
|
||||
enforced=development_status >= ENFORCED_DEVELOPMENT_STATUS and not exempt,
|
||||
exempt=exempt,
|
||||
)
|
||||
)
|
||||
|
||||
if not policies:
|
||||
raise ValueError(f"No package pyproject.toml files found below {packages_dir}")
|
||||
return policies
|
||||
|
||||
|
||||
def parse_coverage_xml(xml_path: Path) -> tuple[dict[str, CoverageStats], float, float]:
|
||||
"""Parse Cobertura XML and aggregate coverage by package directory."""
|
||||
root = ET.parse(xml_path).getroot() # ruff:ignore[suspicious-xml-element-tree-usage] # Trusted CI-generated coverage report.
|
||||
package_stats: dict[str, CoverageStats] = {}
|
||||
|
||||
for class_elem in root.findall(".//class"):
|
||||
file_path = normalize_coverage_path(class_elem.get("filename", ""))
|
||||
path_parts = file_path.split("/")
|
||||
try:
|
||||
packages_index = path_parts.index("packages")
|
||||
package_directory = path_parts[packages_index + 1]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
stats = package_stats.setdefault(package_directory, CoverageStats())
|
||||
for line in class_elem.findall(".//line"):
|
||||
stats.lines_valid += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
stats.lines_covered += 1
|
||||
|
||||
if line.get("branch") != "true":
|
||||
continue
|
||||
condition_coverage = line.get("condition-coverage", "")
|
||||
match = re.search(r"\((\d+)/(\d+)\)", condition_coverage)
|
||||
if match is not None:
|
||||
stats.branches_covered += int(match.group(1))
|
||||
stats.branches_valid += int(match.group(2))
|
||||
|
||||
return (
|
||||
package_stats,
|
||||
float(root.get("line-rate", 0)) * 100,
|
||||
float(root.get("branch-rate", 0)) * 100,
|
||||
)
|
||||
|
||||
|
||||
def check_coverage(xml_path: Path, threshold: float, packages_dir: Path) -> bool:
|
||||
"""Check all lifecycle-enforced packages against the coverage threshold."""
|
||||
policies = load_package_policies(packages_dir)
|
||||
package_stats, overall_line_coverage, overall_branch_coverage = parse_coverage_xml(xml_path)
|
||||
|
||||
print("\n" + "=" * 110)
|
||||
print("PYTHON PACKAGE TEST COVERAGE")
|
||||
print("=" * 110)
|
||||
print(f"Overall Line Coverage: {overall_line_coverage:.1f}%")
|
||||
print(f"Overall Branch Coverage: {overall_branch_coverage:.1f}%")
|
||||
print(f"Enforced Threshold: {threshold:.1f}%")
|
||||
print("-" * 110)
|
||||
print(f"{'Package':<48} {'Stage':<20} {'Policy':<14} {'Lines':<12} {'Line Cov':<10}")
|
||||
print("-" * 110)
|
||||
|
||||
failed_packages: list[str] = []
|
||||
for policy in sorted(policies, key=lambda item: (not item.enforced, item.distribution_name)):
|
||||
stats = package_stats.get(policy.directory)
|
||||
if policy.exempt:
|
||||
policy_label = "EXEMPT"
|
||||
elif policy.enforced:
|
||||
policy_label = "ENFORCED"
|
||||
else:
|
||||
policy_label = "REPORT ONLY"
|
||||
|
||||
if stats is None:
|
||||
lines = "-"
|
||||
coverage = "missing"
|
||||
if policy.enforced:
|
||||
failed_packages.append(f"{policy.distribution_name} (missing from coverage report)")
|
||||
else:
|
||||
lines = f"{stats.lines_covered}/{stats.lines_valid}"
|
||||
coverage = f"{stats.line_coverage_percent:.1f}%"
|
||||
if policy.enforced and stats.line_coverage_percent < threshold:
|
||||
failed_packages.append(f"{policy.distribution_name} ({coverage})")
|
||||
|
||||
stage = f"{policy.development_status} - {policy.development_status_label}"
|
||||
print(f"{policy.distribution_name:<48} {stage:<20} {policy_label:<14} {lines:<12} {coverage:<10}")
|
||||
|
||||
print("-" * 110)
|
||||
if failed_packages:
|
||||
print(f"\nFAILED: Enforced packages below {threshold:.1f}% or missing:")
|
||||
for package in failed_packages:
|
||||
print(f" - {package}")
|
||||
return False
|
||||
|
||||
print(f"\nPASSED: All non-exempt Beta-or-higher packages meet {threshold:.1f}% line coverage.")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the coverage policy check."""
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
|
||||
return 1
|
||||
|
||||
try:
|
||||
threshold = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Error: Invalid threshold value: {sys.argv[2]}")
|
||||
return 1
|
||||
|
||||
repository_root = Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
passed = check_coverage(
|
||||
Path(sys.argv[1]),
|
||||
threshold,
|
||||
repository_root / "python" / "packages",
|
||||
)
|
||||
except (FileNotFoundError, ET.ParseError, ValueError) as error:
|
||||
print(f"Error: {error}")
|
||||
return 1
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,170 +0,0 @@
|
||||
// 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,12 +16,7 @@ const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({
|
||||
payloadIssue = undefined,
|
||||
payloadPullRequest = undefined,
|
||||
apiUser = 'api-user',
|
||||
teamState = 'active',
|
||||
} = {}) {
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
@@ -29,16 +24,8 @@ function createMocks({
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const payload = {};
|
||||
if (payloadIssue !== undefined) {
|
||||
payload.issue = payloadIssue;
|
||||
}
|
||||
if (payloadPullRequest !== undefined) {
|
||||
payload.pull_request = payloadPullRequest;
|
||||
}
|
||||
|
||||
const context = {
|
||||
payload,
|
||||
payload: { issue: payloadIssue },
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
@@ -49,11 +36,6 @@ function createMocks({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
@@ -82,37 +64,6 @@ 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 });
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# ruff:file-ignore[implicit-namespace-package, undocumented-public-class, undocumented-public-method]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "python_check_coverage.py"
|
||||
SPEC = importlib.util.spec_from_file_location("python_check_coverage", SCRIPT_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load {SCRIPT_PATH}")
|
||||
coverage_checker = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = coverage_checker
|
||||
SPEC.loader.exec_module(coverage_checker)
|
||||
|
||||
|
||||
class CoveragePolicyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.packages_dir = self.root / "packages"
|
||||
self.packages_dir.mkdir()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def write_package(self, directory: str, name: str, status: str) -> None:
|
||||
package_dir = self.packages_dir / directory
|
||||
package_dir.mkdir()
|
||||
(package_dir / "pyproject.toml").write_text(
|
||||
f"""
|
||||
[project]
|
||||
name = "{name}"
|
||||
classifiers = ["Development Status :: {status}"]
|
||||
""".strip()
|
||||
)
|
||||
|
||||
def write_coverage(self, files: dict[str, list[int]]) -> Path:
|
||||
classes = []
|
||||
total_lines = 0
|
||||
covered_lines = 0
|
||||
for file_path, hits in files.items():
|
||||
lines = []
|
||||
for line_number, hit_count in enumerate(hits, start=1):
|
||||
total_lines += 1
|
||||
covered_lines += hit_count > 0
|
||||
lines.append(f'<line number="{line_number}" hits="{hit_count}"/>')
|
||||
classes.append(f'<class filename="{file_path}"><lines>{"".join(lines)}</lines></class>')
|
||||
|
||||
line_rate = covered_lines / total_lines if total_lines else 0
|
||||
xml_path = self.root / "coverage.xml"
|
||||
xml_path.write_text(
|
||||
f"""
|
||||
<coverage line-rate="{line_rate}" branch-rate="0">
|
||||
<packages>
|
||||
<package name="test">
|
||||
<classes>{"".join(classes)}</classes>
|
||||
</package>
|
||||
</packages>
|
||||
</coverage>
|
||||
""".strip()
|
||||
)
|
||||
return xml_path
|
||||
|
||||
def test_load_package_policies_uses_lifecycle_exemptions(self) -> None:
|
||||
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
self.write_package("stable", "agent-framework-stable", "5 - Production/Stable")
|
||||
self.write_package("devui", "agent-framework-devui", "4 - Beta")
|
||||
self.write_package("lab", "agent-framework-lab", "4 - Beta")
|
||||
|
||||
policies = {policy.directory: policy for policy in coverage_checker.load_package_policies(self.packages_dir)}
|
||||
|
||||
self.assertFalse(policies["alpha"].enforced)
|
||||
self.assertTrue(policies["beta"].enforced)
|
||||
self.assertTrue(policies["stable"].enforced)
|
||||
self.assertTrue(policies["devui"].exempt)
|
||||
self.assertFalse(policies["devui"].enforced)
|
||||
self.assertTrue(policies["lab"].exempt)
|
||||
self.assertFalse(policies["lab"].enforced)
|
||||
|
||||
def test_load_package_policies_rejects_missing_lifecycle(self) -> None:
|
||||
package_dir = self.packages_dir / "missing"
|
||||
package_dir.mkdir()
|
||||
(package_dir / "pyproject.toml").write_text('[project]\nname = "agent-framework-missing"\n')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "exactly one Development Status"):
|
||||
coverage_checker.load_package_policies(self.packages_dir)
|
||||
|
||||
def test_parse_coverage_aggregates_nested_modules_by_distribution(self) -> None:
|
||||
xml_path = self.write_coverage({
|
||||
"packages/core/agent_framework/_agents.py": [1, 0],
|
||||
"packages/core/agent_framework/_workflows/_workflow.py": [1, 1],
|
||||
})
|
||||
|
||||
package_stats, _, _ = coverage_checker.parse_coverage_xml(xml_path)
|
||||
|
||||
self.assertEqual(package_stats["core"].lines_valid, 4)
|
||||
self.assertEqual(package_stats["core"].lines_covered, 3)
|
||||
|
||||
def test_beta_package_below_threshold_fails(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1, 0]})
|
||||
|
||||
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_missing_beta_package_fails(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({})
|
||||
|
||||
self.assertFalse(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_alpha_and_exempt_packages_do_not_fail(self) -> None:
|
||||
self.write_package("alpha", "agent-framework-alpha", "3 - Alpha")
|
||||
self.write_package("devui", "agent-framework-devui", "4 - Beta")
|
||||
self.write_package("lab", "agent-framework-lab", "4 - Beta")
|
||||
xml_path = self.write_coverage({})
|
||||
|
||||
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
def test_beta_package_at_threshold_passes(self) -> None:
|
||||
self.write_package("beta", "agent-framework-beta", "4 - Beta")
|
||||
xml_path = self.write_coverage({"packages/beta/agent_framework_beta/client.py": [1] * 17 + [0] * 3})
|
||||
|
||||
self.assertTrue(coverage_checker.check_coverage(xml_path, 85, self.packages_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,212 +0,0 @@
|
||||
// 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -32,7 +31,6 @@ 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 }}
|
||||
@@ -66,31 +64,6 @@ 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
|
||||
@@ -98,16 +71,31 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.PR_NUMBER,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; proceeding with review.`);
|
||||
@@ -119,10 +107,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
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
|
||||
|
||||
@@ -163,7 +163,6 @@ 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
|
||||
|
||||
@@ -9,31 +9,16 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Immutable commit SHA to check out"
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
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:
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
name: GitHub automation tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
- ".github/workflows/github-automation-tests.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/scripts/**"
|
||||
- ".github/tests/**"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
- ".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"
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: node --test .github/tests/*.js
|
||||
|
||||
- name: Run Python tests
|
||||
run: python .github/tests/test_python_check_coverage.py
|
||||
@@ -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 an immutable commit SHA so they check out and test the approved code.
|
||||
# passing a ref so they check out and test the correct code.
|
||||
# Changed paths are detected here so only the relevant test suites run.
|
||||
#
|
||||
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
|
||||
@@ -37,50 +38,67 @@ 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: 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
|
||||
- name: Resolve 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 }}
|
||||
BASE_REF: ${{ steps.resolve.outputs.base-ref }}
|
||||
CHECKOUT_REF: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/$BASE_REF...$CHECKOUT_REF" \
|
||||
--jq '.files[].filename')
|
||||
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
|
||||
|
||||
DOTNET_CHANGES=false
|
||||
PYTHON_CHANGES=false
|
||||
@@ -95,41 +113,22 @@ 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:
|
||||
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 }}
|
||||
secrets: inherit
|
||||
|
||||
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:
|
||||
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 }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -33,7 +33,6 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
@@ -69,27 +68,10 @@ jobs:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
sparse-checkout: .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
|
||||
@@ -98,7 +80,7 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -124,10 +106,6 @@ jobs:
|
||||
|| needs.team_check.outputs.is_team_member == 'false'
|
||||
}}
|
||||
environment: integration
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
|
||||
@@ -10,39 +10,12 @@ 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: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
// Get the issue body and title
|
||||
const body = context.payload.issue.body
|
||||
@@ -51,14 +24,21 @@ jobs:
|
||||
// Define the labels array
|
||||
let labels = []
|
||||
|
||||
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,
|
||||
})
|
||||
// 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
|
||||
}
|
||||
|
||||
// Only add triage label if the author is not in the team
|
||||
if (!isTeamMember) {
|
||||
|
||||
@@ -13,47 +13,27 @@ 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:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
sparse-checkout: .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: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const { syncBreakingChangeLabelFromTitle } = require('./.github/scripts/title_prefix.js');
|
||||
await syncBreakingChangeLabelFromTitle({ github, context, core });
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -22,35 +21,16 @@ 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:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
sparse-checkout: .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
|
||||
@@ -58,7 +38,7 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -77,39 +57,20 @@ 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:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
sparse-checkout: .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: ${{ steps.github-auth.outputs.token }}
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
|
||||
await enforcePrLimit({
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Check Python test coverage against threshold for enforced targets.
|
||||
|
||||
This script parses a Cobertura XML coverage report and enforces a minimum
|
||||
coverage threshold on specific targets. Targets can be package names
|
||||
(e.g., "packages.core.agent_framework") or individual Python file paths
|
||||
(e.g., "packages/core/agent_framework/observability.py").
|
||||
|
||||
Non-enforced targets are reported for visibility but don't block the build.
|
||||
|
||||
Usage:
|
||||
python python-check-coverage.py <coverage-xml-path> <threshold>
|
||||
|
||||
Example:
|
||||
python python-check-coverage.py python-coverage.xml 85
|
||||
"""
|
||||
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
|
||||
# =============================================================================
|
||||
# ENFORCED TARGETS CONFIGURATION
|
||||
# =============================================================================
|
||||
# Add or remove entries from this set to control which targets must meet
|
||||
# the coverage threshold. Only these targets will fail the build if below
|
||||
# threshold. Other targets are reported for visibility only.
|
||||
#
|
||||
# Target values can be:
|
||||
# - Package paths as they appear in the coverage report
|
||||
# (e.g., "packages.azure-ai.agent_framework_azure_ai")
|
||||
# - Python source file paths as they appear in the coverage report
|
||||
# (e.g., "packages/core/agent_framework/observability.py")
|
||||
# =============================================================================
|
||||
ENFORCED_TARGETS: set[str] = {
|
||||
# Packages (sorted alphabetically)
|
||||
"packages.anthropic.agent_framework_anthropic",
|
||||
"packages.azure-ai-search.agent_framework_azure_ai_search",
|
||||
"packages.core.agent_framework",
|
||||
"packages.core.agent_framework._workflows",
|
||||
"packages.foundry.agent_framework_foundry",
|
||||
"packages.openai.agent_framework_openai",
|
||||
"packages.purview.agent_framework_purview",
|
||||
# Individual files (if you want to enforce specific files instead of whole packages)
|
||||
"packages/core/agent_framework/observability.py",
|
||||
# Add more targets here as coverage improves
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageCoverage:
|
||||
"""Coverage data for a single package."""
|
||||
|
||||
name: str
|
||||
line_rate: float
|
||||
branch_rate: float
|
||||
lines_valid: int
|
||||
lines_covered: int
|
||||
branches_valid: int
|
||||
branches_covered: int
|
||||
|
||||
@property
|
||||
def line_coverage_percent(self) -> float:
|
||||
"""Return line coverage as a percentage."""
|
||||
return self.line_rate * 100
|
||||
|
||||
@property
|
||||
def branch_coverage_percent(self) -> float:
|
||||
"""Return branch coverage as a percentage."""
|
||||
return self.branch_rate * 100
|
||||
|
||||
|
||||
def normalize_coverage_path(path: str) -> str:
|
||||
"""Normalize coverage paths for reliable matching."""
|
||||
return path.replace("\\", "/").lstrip("./")
|
||||
|
||||
|
||||
def parse_coverage_xml(
|
||||
xml_path: str,
|
||||
) -> tuple[dict[str, PackageCoverage], dict[str, PackageCoverage], float, float]:
|
||||
"""Parse Cobertura XML and extract per-package coverage data.
|
||||
|
||||
Args:
|
||||
xml_path: Path to the Cobertura XML coverage report.
|
||||
|
||||
Returns:
|
||||
A tuple of (packages_dict, files_dict, overall_line_rate, overall_branch_rate).
|
||||
"""
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Get overall coverage from root element
|
||||
overall_line_rate = float(root.get("line-rate", 0))
|
||||
overall_branch_rate = float(root.get("branch-rate", 0))
|
||||
|
||||
packages: dict[str, PackageCoverage] = {}
|
||||
file_stats: dict[str, dict[str, int]] = {}
|
||||
|
||||
for package in root.findall(".//package"):
|
||||
package_path = package.get("name", "unknown")
|
||||
|
||||
line_rate = float(package.get("line-rate", 0))
|
||||
branch_rate = float(package.get("branch-rate", 0))
|
||||
|
||||
# Count lines and branches from classes within this package
|
||||
lines_valid = 0
|
||||
lines_covered = 0
|
||||
branches_valid = 0
|
||||
branches_covered = 0
|
||||
|
||||
for class_elem in package.findall(".//class"):
|
||||
file_path = normalize_coverage_path(class_elem.get("filename", ""))
|
||||
if file_path and file_path not in file_stats:
|
||||
file_stats[file_path] = {
|
||||
"lines_valid": 0,
|
||||
"lines_covered": 0,
|
||||
"branches_valid": 0,
|
||||
"branches_covered": 0,
|
||||
}
|
||||
|
||||
for line in class_elem.findall(".//line"):
|
||||
lines_valid += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
lines_covered += 1
|
||||
|
||||
if file_path:
|
||||
file_stats[file_path]["lines_valid"] += 1
|
||||
if int(line.get("hits", 0)) > 0:
|
||||
file_stats[file_path]["lines_covered"] += 1
|
||||
|
||||
# Branch coverage from line elements
|
||||
if line.get("branch") == "true":
|
||||
condition_coverage = line.get("condition-coverage", "")
|
||||
if condition_coverage:
|
||||
# Parse "X% (covered/total)" format
|
||||
try:
|
||||
coverage_parts = (
|
||||
condition_coverage.split("(")[1].rstrip(")").split("/")
|
||||
)
|
||||
branches_covered += int(coverage_parts[0])
|
||||
branches_valid += int(coverage_parts[1])
|
||||
if file_path:
|
||||
file_stats[file_path]["branches_covered"] += int(
|
||||
coverage_parts[0]
|
||||
)
|
||||
file_stats[file_path]["branches_valid"] += int(
|
||||
coverage_parts[1]
|
||||
)
|
||||
except (IndexError, ValueError):
|
||||
# Ignore malformed condition-coverage strings; treat this line as having no branch data.
|
||||
pass
|
||||
|
||||
# Use full package path as the key (no aggregation)
|
||||
packages[package_path] = PackageCoverage(
|
||||
name=package_path,
|
||||
line_rate=line_rate if lines_valid == 0 else lines_covered / lines_valid,
|
||||
branch_rate=branch_rate
|
||||
if branches_valid == 0
|
||||
else branches_covered / branches_valid,
|
||||
lines_valid=lines_valid,
|
||||
lines_covered=lines_covered,
|
||||
branches_valid=branches_valid,
|
||||
branches_covered=branches_covered,
|
||||
)
|
||||
|
||||
files: dict[str, PackageCoverage] = {}
|
||||
for file_path, stats in file_stats.items():
|
||||
lines_valid = stats["lines_valid"]
|
||||
lines_covered = stats["lines_covered"]
|
||||
branches_valid = stats["branches_valid"]
|
||||
branches_covered = stats["branches_covered"]
|
||||
|
||||
files[file_path] = PackageCoverage(
|
||||
name=file_path,
|
||||
line_rate=0 if lines_valid == 0 else lines_covered / lines_valid,
|
||||
branch_rate=0 if branches_valid == 0 else branches_covered / branches_valid,
|
||||
lines_valid=lines_valid,
|
||||
lines_covered=lines_covered,
|
||||
branches_valid=branches_valid,
|
||||
branches_covered=branches_covered,
|
||||
)
|
||||
|
||||
return packages, files, overall_line_rate, overall_branch_rate
|
||||
|
||||
|
||||
def format_coverage_value(coverage: float, threshold: float, is_enforced: bool) -> str:
|
||||
"""Format a coverage value with optional pass/fail indicator.
|
||||
|
||||
Args:
|
||||
coverage: Coverage percentage (0-100).
|
||||
threshold: Minimum required coverage percentage.
|
||||
is_enforced: Whether this target is enforced.
|
||||
|
||||
Returns:
|
||||
Formatted string like "85.5%" or "85.5% ✅" or "75.0% ❌".
|
||||
"""
|
||||
formatted = f"{coverage:.1f}%"
|
||||
if is_enforced:
|
||||
icon = "✅" if coverage >= threshold else "❌"
|
||||
formatted = f"{formatted} {icon}"
|
||||
return formatted
|
||||
|
||||
|
||||
def print_coverage_table(
|
||||
packages: dict[str, PackageCoverage],
|
||||
files: dict[str, PackageCoverage],
|
||||
threshold: float,
|
||||
overall_line_rate: float,
|
||||
overall_branch_rate: float,
|
||||
) -> None:
|
||||
"""Print a formatted coverage summary table.
|
||||
|
||||
Args:
|
||||
packages: Dictionary of package name to coverage data.
|
||||
files: Dictionary of file path to coverage data, used for per-file enforcement.
|
||||
threshold: Minimum required coverage percentage.
|
||||
overall_line_rate: Overall line coverage rate (0-1).
|
||||
overall_branch_rate: Overall branch coverage rate (0-1).
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("PYTHON TEST COVERAGE REPORT")
|
||||
print("=" * 80)
|
||||
|
||||
# Overall coverage
|
||||
print(f"\nOverall Line Coverage: {overall_line_rate * 100:.1f}%")
|
||||
print(f"Overall Branch Coverage: {overall_branch_rate * 100:.1f}%")
|
||||
print(f"Threshold: {threshold}%")
|
||||
|
||||
enforced_targets = {normalize_coverage_path(t) for t in ENFORCED_TARGETS}
|
||||
|
||||
# Package table
|
||||
print("\n" + "-" * 110)
|
||||
print(f"{'Package':<80} {'Lines':<15} {'Line Cov':<15}")
|
||||
print("-" * 110)
|
||||
|
||||
# Sort: enforced package targets first, then alphabetically
|
||||
sorted_packages = sorted(
|
||||
packages.values(),
|
||||
key=lambda p: (p.name not in ENFORCED_TARGETS, p.name),
|
||||
)
|
||||
|
||||
for pkg in sorted_packages:
|
||||
is_enforced = normalize_coverage_path(pkg.name) in enforced_targets
|
||||
enforced_marker = "[ENFORCED] " if is_enforced else ""
|
||||
line_cov = format_coverage_value(
|
||||
pkg.line_coverage_percent, threshold, is_enforced
|
||||
)
|
||||
lines_info = f"{pkg.lines_covered}/{pkg.lines_valid}"
|
||||
package_label = f"{enforced_marker}{pkg.name}"
|
||||
|
||||
print(f"{package_label:<80} {lines_info:<15} {line_cov:<15}")
|
||||
|
||||
print("-" * 110)
|
||||
|
||||
# Enforced file/model entries (if configured)
|
||||
enforced_files = [
|
||||
files[target]
|
||||
for target in sorted(enforced_targets)
|
||||
if target in files and target.endswith(".py")
|
||||
]
|
||||
|
||||
if enforced_files:
|
||||
print("\nEnforced Files/Models")
|
||||
print("-" * 110)
|
||||
print(f"{'File':<80} {'Lines':<15} {'Line Cov':<15}")
|
||||
print("-" * 110)
|
||||
|
||||
for file_cov in enforced_files:
|
||||
line_cov = format_coverage_value(
|
||||
file_cov.line_coverage_percent, threshold, True
|
||||
)
|
||||
lines_info = f"{file_cov.lines_covered}/{file_cov.lines_valid}"
|
||||
print(f"[ENFORCED] {file_cov.name:<69} {lines_info:<15} {line_cov:<15}")
|
||||
|
||||
print("-" * 110)
|
||||
|
||||
|
||||
def check_coverage(xml_path: str, threshold: float) -> bool:
|
||||
"""Check if all enforced targets meet the coverage threshold.
|
||||
|
||||
Args:
|
||||
xml_path: Path to the Cobertura XML coverage report.
|
||||
threshold: Minimum required coverage percentage.
|
||||
|
||||
Returns:
|
||||
True if all enforced targets pass, False otherwise.
|
||||
"""
|
||||
packages, files, overall_line_rate, overall_branch_rate = parse_coverage_xml(
|
||||
xml_path
|
||||
)
|
||||
|
||||
print_coverage_table(
|
||||
packages, files, threshold, overall_line_rate, overall_branch_rate
|
||||
)
|
||||
|
||||
# Check enforced targets
|
||||
failed_targets: list[str] = []
|
||||
missing_targets: list[str] = []
|
||||
|
||||
for target_name in ENFORCED_TARGETS:
|
||||
normalized_target = normalize_coverage_path(target_name)
|
||||
package_alias = normalized_target.replace("/", ".")
|
||||
|
||||
target_coverage = None
|
||||
if target_name in packages:
|
||||
target_coverage = packages[target_name]
|
||||
elif normalized_target in files:
|
||||
target_coverage = files[normalized_target]
|
||||
elif package_alias in packages:
|
||||
target_coverage = packages[package_alias]
|
||||
|
||||
if target_coverage is None:
|
||||
missing_targets.append(target_name)
|
||||
continue
|
||||
|
||||
if target_coverage.line_coverage_percent < threshold:
|
||||
failed_targets.append(
|
||||
f"{target_name} ({target_coverage.line_coverage_percent:.1f}%)"
|
||||
)
|
||||
|
||||
# Report results
|
||||
if missing_targets:
|
||||
print(
|
||||
f"\n❌ FAILED: Enforced targets not found in coverage report: {', '.join(missing_targets)}"
|
||||
)
|
||||
return False
|
||||
|
||||
if failed_targets:
|
||||
print(
|
||||
f"\n❌ FAILED: The following enforced targets are below {threshold}% coverage threshold:"
|
||||
)
|
||||
for target in failed_targets:
|
||||
print(f" - {target}")
|
||||
print("\nTo fix: Add more tests to improve coverage for the failing targets.")
|
||||
return False
|
||||
|
||||
if ENFORCED_TARGETS:
|
||||
found_enforced = [
|
||||
target
|
||||
for target in ENFORCED_TARGETS
|
||||
if target in packages or normalize_coverage_path(target) in files
|
||||
]
|
||||
if found_enforced:
|
||||
print(
|
||||
f"\n✅ PASSED: All enforced targets meet the {threshold}% coverage threshold."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point.
|
||||
|
||||
Returns:
|
||||
Exit code: 0 for success, 1 for failure.
|
||||
"""
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <coverage-xml-path> <threshold>")
|
||||
print(f"Example: {sys.argv[0]} python-coverage.xml 85")
|
||||
return 1
|
||||
|
||||
xml_path = sys.argv[1]
|
||||
try:
|
||||
threshold = float(sys.argv[2])
|
||||
except ValueError:
|
||||
print(f"Error: Invalid threshold value: {sys.argv[2]}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
success = check_coverage(xml_path, threshold)
|
||||
return 0 if success else 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Coverage file not found: {xml_path}")
|
||||
return 1
|
||||
except ET.ParseError as e:
|
||||
print(f"Error: Failed to parse coverage XML: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -13,27 +13,13 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Immutable commit SHA to check out"
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
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
|
||||
@@ -113,9 +99,6 @@ 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
|
||||
@@ -241,7 +224,6 @@ 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
|
||||
@@ -278,9 +260,6 @@ 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
|
||||
@@ -345,9 +324,6 @@ 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
|
||||
@@ -402,9 +378,6 @@ 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
|
||||
|
||||
@@ -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 agent-framework-azure-cosmos-memory' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -71,7 +71,6 @@ 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'
|
||||
@@ -346,7 +345,6 @@ 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
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
paths:
|
||||
- "python/packages/**"
|
||||
- "python/tests/unit/**"
|
||||
- "python/scripts/workspace_poe_tasks.py"
|
||||
- ".github/scripts/python_check_coverage.py"
|
||||
- ".github/workflows/python-test-coverage.yml"
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -40,10 +37,10 @@ jobs:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run aggregate tests with coverage report
|
||||
- name: Run all tests with coverage report
|
||||
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
- name: Check coverage threshold
|
||||
run: python ${{ github.workspace }}/.github/scripts/python_check_coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
|
||||
@@ -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 agent-framework-azure-cosmos-memory' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -26,30 +26,13 @@ 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'
|
||||
@@ -60,7 +43,7 @@ jobs:
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
|
||||
@@ -204,8 +204,7 @@ 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. Reads return independent working copies so running from one continuation
|
||||
point does not mutate the stored snapshot or another simultaneous branch:
|
||||
target and creates the session on first use:
|
||||
|
||||
For agent targets:
|
||||
|
||||
@@ -228,11 +227,6 @@ 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.
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
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,6 @@ 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. |
|
||||
| `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. |
|
||||
@@ -93,7 +91,6 @@ 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(...)`;
|
||||
@@ -181,9 +178,6 @@ 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
|
||||
@@ -251,100 +245,6 @@ 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
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
---
|
||||
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));
|
||||
});
|
||||
```
|
||||
@@ -60,7 +60,7 @@
|
||||
<PackageVersion Include="AGUI.Client" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Server" Version="0.0.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -80,11 +80,11 @@
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" /> <!-- Pin patched OpenAPI.NET to remediate GHSA-v5pm-xwqc-g5wc -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
@@ -102,10 +102,10 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.InMemory" Version="1.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.5" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
@@ -122,7 +122,6 @@
|
||||
<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,7 +29,6 @@
|
||||
<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" />
|
||||
@@ -66,8 +65,6 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step22_AgentMode/Agent_Step22_AgentMode.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step23_TodoList/Agent_Step23_TodoList.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -203,7 +200,6 @@
|
||||
<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" />
|
||||
@@ -317,19 +313,7 @@
|
||||
<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/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 Name="/Samples/04-hosting/">
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/" />
|
||||
@@ -655,9 +639,7 @@
|
||||
<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/">
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/" />
|
||||
<Folder Name="/Tests/IntegrationTests/">
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
|
||||
@@ -329,80 +329,6 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step20_DynamicFunctionTools",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"=== Dynamic Function Tools Sample ===",
|
||||
"=== Non-Streaming Mode ===",
|
||||
"=== Streaming Mode ===",
|
||||
"[User]",
|
||||
"[Agent]",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show the agent starting with only a RequestTools function and dynamically loading additional tools (weather, time, temperature) as needed.",
|
||||
"The output should contain weather information for Seattle and London, the current time in New York, and a Fahrenheit-to-Celsius temperature conversion.",
|
||||
"The output should demonstrate both non-streaming and streaming modes.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step21_ShellWithEnvironment",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"### Stateless mode",
|
||||
"### Persistent mode",
|
||||
"--- Captured environment snapshot ---",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent using a shell tool to print the current working directory.",
|
||||
"The output should demonstrate that in stateless mode side effects (such as changing directory) do not carry between calls, while in persistent mode the working directory and an environment variable (DEMO_TOKEN set to 'hello-world') carry across calls.",
|
||||
"The output should include a captured environment snapshot describing the OS, shell, and working directory.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step22_AgentMode",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step22_AgentMode",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
SkipReason = "Interactive sample that reads console input in a loop and does not exit on its own.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step23_TodoList",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step23_TodoList",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"User:",
|
||||
"Agent:",
|
||||
"--- Current todo list ---",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent planning a team offsite by breaking the work into a todo list.",
|
||||
"The output should show the todo list being updated as progress is reported (for example marking items complete after the venue is booked and invites are sent) and adjusted when the plan changes to skip catering and add a group hike.",
|
||||
"The current todo list should be printed after each turn, showing item status.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentSkills ─────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
@@ -501,15 +427,6 @@ 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
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.15.0</VersionPrefix>
|
||||
<VersionPrefix>1.13.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260722</DateSuffix>
|
||||
<DateSuffix>260703</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.15.0</GitTag>
|
||||
<GitTag>1.13.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -44,12 +44,6 @@ 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,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<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
@@ -1,11 +0,0 @@
|
||||
apiVersion: dapr.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: ollama
|
||||
spec:
|
||||
type: conversation.ollama
|
||||
metadata:
|
||||
- name: model
|
||||
value: llama3.2
|
||||
- name: cacheTTL
|
||||
value: 10m
|
||||
@@ -1,36 +0,0 @@
|
||||
// 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."));
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -5,10 +5,10 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
|
||||
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";
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -7,10 +7,10 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
<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
@@ -1,195 +0,0 @@
|
||||
// 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
@@ -1,156 +0,0 @@
|
||||
// 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
@@ -1,75 +0,0 @@
|
||||
# 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,7 +9,6 @@ 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.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Samples;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
|
||||
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";
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.Qdrant" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.Qdrant;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.Qdrant;
|
||||
using Qdrant.Client;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
@@ -134,6 +134,6 @@ internal sealed class DocumentationChunk
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
[VectorStoreData]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
[VectorStoreVector(dimensions: 3072)]
|
||||
[VectorStoreVector(Dimensions: 3072)]
|
||||
public string Embedding => this.Text;
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.InMemory" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -12,10 +12,10 @@ using System.Text.Json;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using CommunityToolkit.VectorData.InMemory;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using SampleApp;
|
||||
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Agent Mode — Switch an agent's operating mode at runtime with AgentModeProvider
|
||||
//
|
||||
// This sample shows how to use the AgentModeProvider, an AIContextProvider that tracks the
|
||||
// agent's current operating "mode" in the session state and exposes tools (mode_get / mode_set)
|
||||
// so the agent can query and switch modes as its work progresses. The mode is folded into the
|
||||
// instructions sent to the model on every turn, so different modes can drive different behavior.
|
||||
//
|
||||
// The sample demonstrates two things:
|
||||
// 1. The built-in default modes ("plan" and "execute") that ship with the provider.
|
||||
// 2. How to customize the available modes via AgentModeProviderOptions.
|
||||
//
|
||||
// It runs a simple interactive loop. In addition to chatting with the agent, you can switch the
|
||||
// agent's mode yourself using a slash command:
|
||||
// /mode — show the current mode
|
||||
// /mode <name> — switch to the named mode
|
||||
// /help — list the available commands and modes
|
||||
// /exit — quit
|
||||
//
|
||||
// When you switch modes with /mode, the provider injects a notification on the next turn so the
|
||||
// agent clearly sees the change and adjusts its behavior accordingly.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// Set AGENT_MODE_USE_CUSTOM=true to run the sample with the custom modes defined below instead of
|
||||
// the provider's built-in "plan" / "execute" defaults.
|
||||
bool useCustomModes = string.Equals(Environment.GetEnvironmentVariable("AGENT_MODE_USE_CUSTOM"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// <create_mode_provider>
|
||||
AgentModeProvider modeProvider;
|
||||
string[] availableModes;
|
||||
|
||||
if (useCustomModes)
|
||||
{
|
||||
// Customize the set of modes by supplying AgentModeProviderOptions. Each mode has a name and a
|
||||
// block of instructions describing how the agent should behave while operating in that mode.
|
||||
// DefaultMode selects the mode new sessions start in (defaults to the first mode when omitted).
|
||||
modeProvider = new AgentModeProvider(new AgentModeProviderOptions
|
||||
{
|
||||
DefaultMode = "concise",
|
||||
Modes =
|
||||
[
|
||||
new AgentModeProviderOptions.AgentMode(
|
||||
"concise",
|
||||
"Answer in a single short sentence. Do not elaborate unless the user explicitly asks for more detail."),
|
||||
new AgentModeProviderOptions.AgentMode(
|
||||
"detailed",
|
||||
"Answer thoroughly. Explain your reasoning, provide examples, and cover relevant edge cases."),
|
||||
],
|
||||
});
|
||||
|
||||
availableModes = ["concise", "detailed"];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the provider's built-in modes: "plan" (interactive planning) and "execute" (autonomous
|
||||
// execution). No options are required.
|
||||
modeProvider = new AgentModeProvider();
|
||||
availableModes = ["plan", "execute"];
|
||||
}
|
||||
// </create_mode_provider>
|
||||
|
||||
// Create the agent and attach the mode provider as an AIContextProvider.
|
||||
// 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.
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ModeAwareAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = model,
|
||||
Instructions = "You are a helpful assistant. Follow the process and behavior required by your current operating mode.",
|
||||
},
|
||||
AIContextProviders = [modeProvider],
|
||||
});
|
||||
|
||||
using var providerToDispose = modeProvider;
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("Agent Mode sample. Type a message to chat, or use a slash command.");
|
||||
Console.WriteLine($"Available modes: {string.Join(", ", availableModes)}");
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
PrintHelp(availableModes);
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine()?.Trim();
|
||||
|
||||
// Treat empty input or end-of-stream (Ctrl+D / Ctrl+Z) as a request to exit.
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.Equals("/help", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
PrintHelp(availableModes);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle the /mode slash command: "/mode" shows the current mode, "/mode <name>" switches to it.
|
||||
if (input.Equals("/mode", StringComparison.OrdinalIgnoreCase) || input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await modeProvider.SetModeAsync(session, parts[1]);
|
||||
Console.WriteLine($"Switched to \"{parts[1]}\" mode.");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// SetModeAsync throws when the requested mode is not one of the configured modes.
|
||||
Console.WriteLine(ex.Message);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything else is a message for the agent. The mode provider injects the current mode (and any
|
||||
// pending mode-change notification) into the context for this turn.
|
||||
Console.WriteLine(await agent.RunAsync(input, session));
|
||||
|
||||
// Print the mode after the turn: the agent may have switched it itself via the mode_set tool as
|
||||
// its work progressed, so this reflects any change the agent made during the turn.
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
}
|
||||
|
||||
static void PrintHelp(string[] availableModes)
|
||||
{
|
||||
Console.WriteLine("Commands:");
|
||||
Console.WriteLine(" /mode Show the current mode");
|
||||
Console.WriteLine($" /mode <name> Switch mode ({string.Join(" | ", availableModes)})");
|
||||
Console.WriteLine(" /help Show this help");
|
||||
Console.WriteLine(" /exit Quit");
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
# Agent Mode
|
||||
|
||||
This sample demonstrates how to use the `AgentModeProvider` to track and switch an agent's
|
||||
operating **mode** at runtime, and drive different agent behavior depending on the active mode.
|
||||
|
||||
The `AgentModeProvider` is an `AIContextProvider` that stores the current mode in the session
|
||||
state and injects it into the instructions sent to the model on every turn. It also exposes
|
||||
`mode_get` and `mode_set` tools so the agent can query and switch modes on its own as its work
|
||||
progresses.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Attaching an `AgentModeProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
|
||||
- The provider's **built-in** modes: `plan` (interactive planning) and `execute` (autonomous execution).
|
||||
- **Customizing** the available modes with `AgentModeProviderOptions` (set the
|
||||
`AGENT_MODE_USE_CUSTOM` environment variable to `true` to switch to a simple `concise` /
|
||||
`detailed` mode set).
|
||||
- Reading and changing the mode from application code with `GetModeAsync` / `SetModeAsync`.
|
||||
- A simple interactive input loop that lets the user switch mode with a slash command. When the
|
||||
mode changes this way, the provider injects a notification on the next turn so the agent adjusts
|
||||
its behavior.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/mode` | Show the current mode |
|
||||
| `/mode <name>` | Switch to the named mode |
|
||||
| `/help` | List the available commands and modes |
|
||||
| `/exit` | Quit (an empty line also exits) |
|
||||
|
||||
Any other input is sent to the agent as a message.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Microsoft Foundry project endpoint and model configured
|
||||
- Azure CLI installed and authenticated (run `az login`)
|
||||
- User has the required role to invoke models in the Foundry project
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
To try the custom modes instead of the built-in `plan` / `execute` modes, set the
|
||||
`AGENT_MODE_USE_CUSTOM` environment variable to `true` and re-run:
|
||||
|
||||
```powershell
|
||||
$env:AGENT_MODE_USE_CUSTOM="true"
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Todo List — Track work items across turns with TodoProvider
|
||||
//
|
||||
// This sample shows how to use the TodoProvider, an AIContextProvider that gives an agent a set of
|
||||
// tools for managing a todo list (todos_add, todos_complete, todos_remove, todos_get_remaining,
|
||||
// todos_get_all) along with instructions on how to use them. The todo list is stored in the
|
||||
// session state and persists across turns, so the agent can plan multi-step work, track progress,
|
||||
// and adjust the list as the conversation evolves.
|
||||
//
|
||||
// This is a scripted, non-interactive walkthrough: it sends a sequence of messages to the agent
|
||||
// and, after each turn, prints the agent's reply followed by the current todo list (read directly
|
||||
// from the provider via GetAllTodosAsync). This lets you watch the todo state evolve as the agent
|
||||
// adds, completes, and removes items.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// <create_todo_provider>
|
||||
// Create the TodoProvider and attach it to the agent as an AIContextProvider. The provider
|
||||
// contributes the todo-management tools and instructions to every agent invocation.
|
||||
using var todoProvider = new TodoProvider();
|
||||
|
||||
// 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.
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "PlanningAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = model,
|
||||
Instructions = "You are a helpful planning assistant. Use your todo list to plan and track multi-step work.",
|
||||
},
|
||||
AIContextProviders = [todoProvider],
|
||||
});
|
||||
// </create_todo_provider>
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// A scripted set of turns that exercises the provider end-to-end: the agent should add todos for a
|
||||
// multi-step request, mark items complete as progress is reported, and adjust the list on a change
|
||||
// of plan.
|
||||
string[] userMessages =
|
||||
[
|
||||
"I'm organizing a small team offsite. Can you help me plan it? Break the work into a todo list.",
|
||||
"I've booked the venue and sent out the invites. Please update the list.",
|
||||
"Actually, let's skip catering and instead plan a group hike. Update the plan accordingly.",
|
||||
];
|
||||
|
||||
foreach (string userMessage in userMessages)
|
||||
{
|
||||
Console.WriteLine($"User: {userMessage}");
|
||||
Console.WriteLine($"Agent: {await agent.RunAsync(userMessage, session)}");
|
||||
|
||||
// Read the current todo list straight from the provider and print it so the state is visible.
|
||||
await PrintTodoListAsync(todoProvider, session);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task PrintTodoListAsync(TodoProvider todoProvider, AgentSession session)
|
||||
{
|
||||
IReadOnlyList<TodoItem> todos = await todoProvider.GetAllTodosAsync(session);
|
||||
|
||||
Console.WriteLine("--- Current todo list ---");
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (empty)");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TodoItem todo in todos)
|
||||
{
|
||||
string status = todo.IsComplete ? "x" : " ";
|
||||
Console.WriteLine($" [{status}] {todo.Id}. {todo.Title}");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# Todo List
|
||||
|
||||
This sample demonstrates how to use the `TodoProvider` to let an agent plan and track multi-step
|
||||
work using a todo list that persists across turns within a session.
|
||||
|
||||
The `TodoProvider` is an `AIContextProvider` that contributes todo-management tools and instructions
|
||||
to the agent, and stores the todo list in the session state. The provider exposes the following
|
||||
tools to the agent:
|
||||
|
||||
- `todos_add` — add one or more todo items (title + optional description).
|
||||
- `todos_complete` — mark one or more items complete, with a reason.
|
||||
- `todos_remove` — remove one or more items by ID.
|
||||
- `todos_get_remaining` — retrieve the incomplete items.
|
||||
- `todos_get_all` — retrieve all items (complete and incomplete).
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Attaching a `TodoProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
|
||||
- The agent breaking a complex request into trackable todo items, marking items complete as
|
||||
progress is reported, and adjusting the list when the plan changes.
|
||||
- Reading the todo list from application code with `TodoProvider.GetAllTodosAsync`.
|
||||
|
||||
This is a **scripted, non-interactive** walkthrough: it sends a fixed sequence of messages and,
|
||||
after each turn, prints the agent's reply followed by the current todo list so you can watch the
|
||||
state evolve.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Microsoft Foundry project endpoint and model configured
|
||||
- Azure CLI installed and authenticated (run `az login`)
|
||||
- User has the required role to invoke models in the Foundry project
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -47,9 +47,6 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|
||||
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|
||||
|[Shell tool with environment-aware system prompt](./Agent_Step21_ShellWithEnvironment/)|This sample demonstrates how to use the shell tool together with the ShellEnvironmentProvider to run commands in stateless and persistent modes, injecting environment-aware instructions so the agent emits commands in the right shell idiom.|
|
||||
|[Switching agent operating mode](./Agent_Step22_AgentMode/)|This sample demonstrates how to use the AgentModeProvider to track and switch an agent's operating mode at runtime, including the built-in plan/execute modes and custom modes, with a simple input loop that switches mode using a slash command.|
|
||||
|[Tracking work with a todo list](./Agent_Step23_TodoList/)|This sample demonstrates how to use the TodoProvider to let an agent plan and track multi-step work using a todo list that persists across turns, printing the evolving todo list after each turn.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+5
-7
@@ -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 shellExecutor = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
{
|
||||
WorkingDirectory = vaultDir,
|
||||
ConfineWorkingDirectory = true,
|
||||
@@ -160,9 +160,7 @@ 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.
|
||||
// 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)];
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
|
||||
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
@@ -172,6 +170,8 @@ 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, CodeAct, and the shell environment provider.
|
||||
// Our skills provider plus CodeAct.
|
||||
AIContextProviders = contextProviders,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
@@ -188,8 +188,6 @@ 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 },
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001;MAAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -65,17 +65,6 @@ public static class Program
|
||||
.AddParticipants([researcherAgent, coderAgent])
|
||||
.WithName("Magentic Orchestration Workflow")
|
||||
.WithDescription("Coordinates a researcher and coder to solve a complex analytical task.")
|
||||
// By default the manager's internally generated messages (task ledger, progress ledger, final answer)
|
||||
// use the built-in English prompts. To have them written in another language, pin a concrete language:
|
||||
// .WithResponseLanguage("French")
|
||||
// For full control you can also override any of the internal prompt templates (placeholders such as
|
||||
// {task}, {team}, and - for the progress ledger - {schema} are substituted by the framework):
|
||||
// .WithPromptOverrides(new MagenticPromptOverrides
|
||||
// {
|
||||
// FinalAnswerPrompt = "Rédige la réponse finale à la demande suivante en français :\n{task}",
|
||||
// })
|
||||
// The built-in English templates you can copy and translate are published on MagenticDefaultPrompts
|
||||
// (e.g. MagenticDefaultPrompts.ProgressLedgerPrompt) - use them as a starting point for your overrides.
|
||||
.RequirePlanSignoff(false)
|
||||
.WithMaxRounds(10)
|
||||
.WithMaxStalls(3)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Client for the HostingResponsesAgent server sample. It shows the two idiomatic ways to consume an
|
||||
// OpenAI Responses endpoint from .NET, both pointed at the same server route (written in the paired Server):
|
||||
//
|
||||
// 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path).
|
||||
// 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path).
|
||||
//
|
||||
// Both run the same three-turn conversation. The third turn only makes sense if the server remembered
|
||||
// the first turn, so it also proves multi-turn session continuity across the rotating response-id chain.
|
||||
|
||||
using System.ClientModel;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("RESPONSES_SERVER_URL") ?? "http://localhost:5000";
|
||||
|
||||
// The server ignores the model id (it runs its own configured agent), but the OpenAI SDK requires one to
|
||||
// shape the request. Reuse FOUNDRY_MODEL for parity with the server sample.
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
string[] prompts =
|
||||
[
|
||||
"What is the weather in Tokyo?",
|
||||
"And what about Amsterdam?",
|
||||
"Which of the two cities we just discussed is warmer?",
|
||||
];
|
||||
|
||||
// A single ResponsesClient pointed at the local server backs both consumption paths. The api key is unused
|
||||
// by the sample server, but the SDK requires a credential.
|
||||
ResponsesClient responseClient = new OpenAIClient(
|
||||
new ApiKeyCredential("not-needed"),
|
||||
new OpenAIClientOptions { Endpoint = new Uri(serverUrl) })
|
||||
.GetResponsesClient();
|
||||
|
||||
Console.WriteLine($"Connecting to {serverUrl}\n");
|
||||
|
||||
await RunWithChatClientAsync(responseClient, model, prompts).ConfigureAwait(false);
|
||||
await RunWithAgentAsync(responseClient, model, prompts).ConfigureAwait(false);
|
||||
|
||||
// CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by
|
||||
// hand: each response carries the server's response id as ChatResponse.ConversationId, which we pass back
|
||||
// as the next turn's ChatOptions.ConversationId. Because it is a "resp_" id, the SDK sends it as
|
||||
// previous_response_id, exactly what the server's GetSessionStoreId reads.
|
||||
static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model, string[] prompts)
|
||||
{
|
||||
Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient ==");
|
||||
IChatClient chatClient = responseClient.AsIChatClient(model);
|
||||
|
||||
string? previousResponseId = null;
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.WriteLine($"User: {prompt}");
|
||||
ChatResponse response = await chatClient.GetResponseAsync(
|
||||
prompt,
|
||||
new ChatOptions { ConversationId = previousResponseId }).ConfigureAwait(false);
|
||||
Console.WriteLine($"Agent: {response.Text}");
|
||||
previousResponseId = response.ConversationId;
|
||||
Console.WriteLine($"Response ID: {previousResponseId}\n");
|
||||
}
|
||||
}
|
||||
|
||||
// MAF path: consume the same endpoint through an Agent Framework AIAgent. A single AgentSession threads the
|
||||
// rotating response-id chain automatically, so the caller only sends the new user message each turn.
|
||||
static async Task RunWithAgentAsync(ResponsesClient responseClient, string model, string[] prompts)
|
||||
{
|
||||
Console.WriteLine("== MAF: Agent Framework AIAgent + AgentSession ==");
|
||||
AIAgent agent = responseClient.AsAIAgent(model: model, name: "HostedResponsesClient");
|
||||
AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(false);
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.WriteLine($"User: {prompt}");
|
||||
AgentResponse response = await agent.RunAsync(prompt, session).ConfigureAwait(false);
|
||||
Console.WriteLine($"Agent: {response.Text}\n");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# Client (Hosting Responses Agent)
|
||||
|
||||
Client half of the [Hosting Responses Agent](../README.md) sample.
|
||||
|
||||
Runs the same three-turn conversation twice against the server's `POST /responses` route, once per
|
||||
consumption path:
|
||||
|
||||
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` from `ResponsesClient.AsIChatClient(model)`.
|
||||
Continuity is threaded by hand: each response's `ChatResponse.ConversationId` (a `resp_` id) is passed
|
||||
back as the next turn's `ChatOptions.ConversationId`, which the SDK sends as `previous_response_id`.
|
||||
- **MAF** — a Microsoft Agent Framework `AIAgent` from `ResponsesClient.AsAIAgent(...)`. A single
|
||||
`AgentSession` threads the rotating response-id chain automatically.
|
||||
|
||||
The third turn asks about the first turn, so a correct answer proves multi-turn session continuity.
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_URL`. Start the server first.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Hosting Responses Agent (client / server)
|
||||
|
||||
A client/server pair showing how to expose an `AIAgent` over the OpenAI Responses protocol from an
|
||||
ASP.NET Core route you write, and how to consume it from .NET two different ways.
|
||||
|
||||
```
|
||||
local_responses/
|
||||
├── Server/ # exposes POST /responses using the OpenAIResponses helpers
|
||||
└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent)
|
||||
```
|
||||
|
||||
## Server
|
||||
|
||||
The server owns routing, authentication, and session storage. The framework provides only the protocol
|
||||
conversion via `OpenAIResponses` (`ToAgentRunRequest`, `GetSessionStoreId`, `WriteResponse` /
|
||||
`WriteResponseStreamAsync`), instead of the batteries-included `MapOpenAIResponses` endpoint. The agent has a
|
||||
deterministic `lookup_weather` tool. Session continuity uses an in-memory `AgentSessionStore` directly. It
|
||||
binds to `http://localhost:5000`.
|
||||
|
||||
See [Server/README.md](Server/README.md).
|
||||
|
||||
## Client
|
||||
|
||||
A single program that runs the same three-turn conversation twice, once per consumption path:
|
||||
|
||||
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
|
||||
- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` (the higher-level agent path).
|
||||
|
||||
Both point at the same server. The third turn asks about the first turn, proving multi-turn session
|
||||
continuity across the rotating response-id chain.
|
||||
|
||||
See [Client/README.md](Client/README.md).
|
||||
|
||||
## Run
|
||||
|
||||
Start the server in one shell:
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini
|
||||
dotnet run --project Server
|
||||
```
|
||||
|
||||
Then run the client in another shell:
|
||||
|
||||
```bash
|
||||
dotnet run --project Client
|
||||
```
|
||||
|
||||
The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_URL`.
|
||||
|
||||
## Security note
|
||||
|
||||
`OpenAIResponses.GetSessionStoreId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a
|
||||
placeholder; a real application must authenticate the caller and authorize/bind the id to the authenticated
|
||||
principal before using it as a session key. For multi-user hosts, scope the store with
|
||||
`IsolationKeyScopedAgentSessionStore`.
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how an application can own its own ASP.NET Core route and expose an AIAgent over the
|
||||
// OpenAI Responses protocol by calling the Agent Framework OpenAIResponses conversion helpers, instead of
|
||||
// using the batteries-included MapOpenAIResponses server. The application keeps control of routing, auth,
|
||||
// and session storage; the helpers provide only the protocol <-> agent conversion.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configuration via environment variables (never hardcode secrets).
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// A deterministic weather tool.
|
||||
[Description("Return a deterministic weather report for a city.")]
|
||||
static string LookupWeather([Description("The city to look up weather for.")] string location)
|
||||
{
|
||||
int highTemp = 5 + (System.Text.Encoding.UTF8.GetBytes(location).Sum(b => b) % 21);
|
||||
return location switch
|
||||
{
|
||||
"Seattle" => $"Seattle is rainy with a high of {highTemp}°C.",
|
||||
"Amsterdam" => $"Amsterdam is cloudy with a high of {highTemp}°C.",
|
||||
"Tokyo" => $"Tokyo is clear with a high of {highTemp}°C.",
|
||||
_ => $"{location} is sunny with a high of {highTemp}°C.",
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a friendly weather assistant. Use the lookup_weather tool for any weather " +
|
||||
"question and answer in one short sentence.",
|
||||
name: "WeatherAgent",
|
||||
tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]);
|
||||
|
||||
// The application owns session storage directly. The in-memory store's GetSessionAsync creates a session
|
||||
// on first use and returns an independent instance per call; no shared holder is needed. A real app that
|
||||
// runs concurrent turns against the same session id owns any coordination it needs.
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// The application owns this route. It parses the OpenAI Responses body with the helpers, runs the agent
|
||||
// itself, and renders the response with the helpers. Binding the body as JsonElement lets ASP.NET Core
|
||||
// deserialize the JSON request body directly, so there is no JsonDocument to own or dispose.
|
||||
app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken cancellationToken) =>
|
||||
{
|
||||
// Parse the request first, then read the continuation id off the parsed request (no second parse).
|
||||
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
|
||||
// The candidate continuation id is untrusted. A real app authenticates the caller and authorizes/binds
|
||||
// this key to the principal before using it. This sample simply falls back to a fresh id.
|
||||
string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run);
|
||||
string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId();
|
||||
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
|
||||
// Choose where to persist the post-run session, which depends on how the caller continued the thread:
|
||||
// - A stable "conversation" id is a MUTABLE HEAD: write the advanced session back under the same id so
|
||||
// the next turn on that conversation sees this turn. Concurrent runs against one conversation id are
|
||||
// NOT serialized here; a production app must provide its own per-conversation single-writer coordination.
|
||||
// - Otherwise (a "previous_response_id" continuation or a first turn) the new response id is an IMMUTABLE
|
||||
// SNAPSHOT: persist under it so a later previous_response_id can branch from this exact point, and two
|
||||
// branches from the same prior response stay independent.
|
||||
string? conversationId = run.ConversationId is { Length: > 0 } cid && cid == sessionStoreId ? cid : null;
|
||||
string saveId = conversationId ?? responseId;
|
||||
|
||||
bool stream = body.TryGetProperty("stream", out JsonElement streamProp) && streamProp.ValueKind == JsonValueKind.True;
|
||||
|
||||
if (stream)
|
||||
{
|
||||
http.Response.ContentType = "text/event-stream";
|
||||
var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, cancellationToken);
|
||||
await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, responseId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
|
||||
await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Persist the post-run session under the selected continuation id (see saveId above).
|
||||
await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The SSE body was already written straight to http.Response above, so return an empty result:
|
||||
// this returns from the handler (the non-streaming code below does not run) without writing a body.
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false);
|
||||
await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId));
|
||||
});
|
||||
|
||||
// Bind to a fixed local URL so the paired client sample has a deterministic default.
|
||||
// Override with the ASPNETCORE_URLS environment variable when needed.
|
||||
app.Run("http://localhost:5000");
|
||||
|
||||
// Application-owned trust decision. Replace with real authentication + authorization: verify the caller,
|
||||
// then authorize/bind the candidate id to the authenticated principal before returning it.
|
||||
static string? Authorize(HttpContext http, string? candidateSessionStoreId) => candidateSessionStoreId;
|
||||
@@ -1,45 +0,0 @@
|
||||
# Server (Hosting Responses Agent)
|
||||
|
||||
Server half of the [Hosting Responses Agent](../README.md) sample.
|
||||
|
||||
Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` route you write:
|
||||
|
||||
- `OpenAIResponses.ToAgentRunRequest(body)` parses the request into messages, run options, and the
|
||||
continuation ids.
|
||||
- `OpenAIResponses.GetSessionStoreId(run)` reads the untrusted continuation-id candidate off the parsed
|
||||
request.
|
||||
- `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the
|
||||
Responses wire shape (non-streaming JSON and SSE).
|
||||
|
||||
Session continuity uses an in-memory `AgentSessionStore` directly. `GetSessionAsync(agent, id)` creates a
|
||||
session on first use and returns an independent instance per call; the store does no internal locking, so a
|
||||
route that runs concurrent turns against the same id owns any coordination it needs.
|
||||
|
||||
The route persists each turn under a continuation id chosen by how the caller continued the thread:
|
||||
|
||||
- A stable **`conversation` id is a mutable head**: the advanced session is written back under the same id,
|
||||
so the next turn on that conversation sees this one. Concurrent runs against a single conversation id are
|
||||
not serialized by the store; a production app must supply its own per-conversation single-writer
|
||||
coordination.
|
||||
- A **`previous_response_id` continuation (or a first turn) is an immutable snapshot**: the session is saved
|
||||
under the newly minted response id, so a later `previous_response_id` can branch from that exact point and
|
||||
two branches from the same prior response stay independent.
|
||||
|
||||
The agent has a deterministic `lookup_weather` tool. Binds to `http://localhost:5000` (override with
|
||||
`ASPNETCORE_URLS`).
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
export FOUNDRY_MODEL="gpt-5.4-mini"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
You can also call it directly with curl:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5000/responses -H "content-type: application/json" \
|
||||
-d '{ "input": "What is the weather in Tokyo?" }'
|
||||
|
||||
curl -N http://localhost:5000/responses -H "content-type: application/json" \
|
||||
-d '{ "input": "What is the weather in Tokyo?", "stream": true }'
|
||||
```
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Client for the local_responses_workflow server sample. Like the agent client, it shows the two idiomatic
|
||||
// ways to consume an OpenAI Responses endpoint from .NET, both pointed at the same workflow route (written in the paired Server):
|
||||
//
|
||||
// 1. CC - a plain Microsoft.Extensions.AI IChatClient (the lower-level chat-client path).
|
||||
// 2. MAF - a Microsoft Agent Framework AIAgent + AgentSession (the higher-level agent path).
|
||||
//
|
||||
// The server implements previous_response_id continuation only (it rejects conversation-id continuity), so
|
||||
// both paths follow the rotating response-id chain: the first turn sends a JSON brief, the follow-up turn
|
||||
// continues from the first turn's response id. The workflow resumes its checkpoint across that chain.
|
||||
|
||||
using System.ClientModel;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("RESPONSES_SERVER_URL") ?? "http://localhost:5001";
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
const string Brief = """{ "topic": "electric SUV", "style": "playful", "audience": "young families" }""";
|
||||
const string FollowUp = "Make it a little more premium, but still family friendly.";
|
||||
|
||||
ResponsesClient responseClient = new OpenAIClient(
|
||||
new ApiKeyCredential("not-needed"),
|
||||
new OpenAIClientOptions { Endpoint = new Uri(serverUrl) })
|
||||
.GetResponsesClient();
|
||||
|
||||
Console.WriteLine($"Connecting to {serverUrl}\n");
|
||||
|
||||
await RunWithChatClientAsync(responseClient, model).ConfigureAwait(false);
|
||||
await RunWithAgentAsync(responseClient, model).ConfigureAwait(false);
|
||||
|
||||
// CC path: consume the endpoint through a Microsoft.Extensions.AI IChatClient. Continuity is threaded by
|
||||
// hand: each response's ChatResponse.ConversationId (a "resp_" id) is passed back as the next turn's
|
||||
// ChatOptions.ConversationId, which the SDK sends as previous_response_id.
|
||||
static async Task RunWithChatClientAsync(ResponsesClient responseClient, string model)
|
||||
{
|
||||
Console.WriteLine("== CC: Microsoft.Extensions.AI IChatClient ==");
|
||||
IChatClient chatClient = responseClient.AsIChatClient(model);
|
||||
|
||||
Console.WriteLine($"User: {Brief}");
|
||||
ChatResponse first = await chatClient.GetResponseAsync(Brief).ConfigureAwait(false);
|
||||
Console.WriteLine($"Workflow: {first.Text}\n");
|
||||
|
||||
Console.WriteLine($"User: {FollowUp}");
|
||||
ChatResponse second = await chatClient.GetResponseAsync(
|
||||
FollowUp,
|
||||
new ChatOptions { ConversationId = first.ConversationId }).ConfigureAwait(false);
|
||||
Console.WriteLine($"Workflow: {second.Text}\n");
|
||||
}
|
||||
|
||||
// MAF path: consume the same endpoint through an Agent Framework AIAgent. A single AgentSession threads the
|
||||
// rotating previous_response_id chain automatically, so the caller only sends the new input each turn.
|
||||
static async Task RunWithAgentAsync(ResponsesClient responseClient, string model)
|
||||
{
|
||||
Console.WriteLine("== MAF: Agent Framework AIAgent + AgentSession ==");
|
||||
AIAgent agent = responseClient.AsAIAgent(model: model, name: "HostedWorkflowClient");
|
||||
AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"User: {Brief}");
|
||||
AgentResponse first = await agent.RunAsync(Brief, session).ConfigureAwait(false);
|
||||
Console.WriteLine($"Workflow: {first.Text}\n");
|
||||
|
||||
Console.WriteLine($"User: {FollowUp}");
|
||||
AgentResponse second = await agent.RunAsync(FollowUp, session).ConfigureAwait(false);
|
||||
Console.WriteLine($"Workflow: {second.Text}\n");
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# Client (Hosting Responses Workflow)
|
||||
|
||||
Client half of the [Hosting Responses Workflow](../README.md) sample.
|
||||
|
||||
Runs the same two-turn conversation twice against the server's `POST /responses` route, once per consumption
|
||||
path:
|
||||
|
||||
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` from `ResponsesClient.AsIChatClient(model)`.
|
||||
- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` from `ResponsesClient.AsAIAgent(...)`.
|
||||
|
||||
Both send a JSON brief on the first turn and a refinement on the second, following the rotating
|
||||
`previous_response_id` chain (the CC path threads it by hand via `ChatOptions.ConversationId`; the MAF path
|
||||
lets `AgentSession` do it). The follow-up only makes sense if the workflow resumed the first turn's
|
||||
checkpoint, so it proves checkpoint continuity.
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Defaults to `http://localhost:5001`; override with `RESPONSES_SERVER_URL`. Start the server first.
|
||||
@@ -1,64 +0,0 @@
|
||||
# Hosting Responses Workflow (client / server)
|
||||
|
||||
A client/server pair showing how to expose a **workflow** over the OpenAI Responses protocol from an
|
||||
ASP.NET Core route you write, with per-session checkpoint resume, and how to consume it from .NET two
|
||||
different ways.
|
||||
|
||||
```
|
||||
local_responses_workflow/
|
||||
├── Server/ # exposes POST /responses; previous_response_id continuation with checkpoint resume
|
||||
└── Client/ # consumes it two ways: CC (IChatClient) and MAF (AIAgent)
|
||||
```
|
||||
|
||||
## Server
|
||||
|
||||
The server owns routing, authentication, and checkpoint storage. It uses the `OpenAIResponses` conversion
|
||||
helpers for the wire protocol and `HostedWorkflowState` for per-session checkpoint resume. The workflow is a
|
||||
brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. The first turn runs the
|
||||
workflow forward; later turns restore the latest checkpoint and run forward with the new brief. It binds to
|
||||
`http://localhost:5001`.
|
||||
|
||||
The server supports **`previous_response_id` continuation only** and **rejects `conversation` continuity
|
||||
with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a cursor that maps each
|
||||
response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed
|
||||
run.
|
||||
|
||||
See [Server/README.md](Server/README.md).
|
||||
|
||||
## Client
|
||||
|
||||
A single program that runs the same two-turn conversation twice, once per consumption path:
|
||||
|
||||
- **CC** — a plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
|
||||
- **MAF** — a Microsoft Agent Framework `AIAgent` + `AgentSession` (the higher-level agent path).
|
||||
|
||||
Both send a JSON brief on the first turn and a refinement on the second, following the rotating
|
||||
`previous_response_id` chain (the CC path threads it by hand; the MAF path lets `AgentSession` do it). The
|
||||
follow-up only makes sense if the workflow resumed the first turn's checkpoint.
|
||||
|
||||
See [Client/README.md](Client/README.md).
|
||||
|
||||
## Run
|
||||
|
||||
Start the server in one shell:
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
export FOUNDRY_MODEL="gpt-5.4-mini" # optional, defaults to gpt-5.4-mini
|
||||
dotnet run --project Server
|
||||
```
|
||||
|
||||
Then run the client in another shell:
|
||||
|
||||
```bash
|
||||
dotnet run --project Client
|
||||
```
|
||||
|
||||
The client defaults to `http://localhost:5001`; override with `RESPONSES_SERVER_URL`.
|
||||
|
||||
## Why previous_response_id needs a cursor
|
||||
|
||||
`previous_response_id` changes every turn, so it cannot key checkpoint storage directly. The app maps each
|
||||
response id to the stable workflow session id, so every id in the rotating chain resumes the same
|
||||
checkpointed run. Sending `conversation` is rejected with HTTP 400 to keep this sample focused on one
|
||||
continuation mode.
|
||||
@@ -1,202 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how an application can own its own ASP.NET Core route and expose a workflow over the
|
||||
// OpenAI Responses protocol. It uses the OpenAIResponses conversion helpers for the wire protocol and
|
||||
// HostedWorkflowState for per-session checkpoint resume. The application keeps control of routing, auth,
|
||||
// and checkpoint storage.
|
||||
//
|
||||
// This server demonstrates previous_response_id continuation ONLY. It rejects conversation-id continuity
|
||||
// with HTTP 400. Because previous_response_id rotates every turn, the app owns a cursor store that maps each
|
||||
// response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed
|
||||
// run.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configuration via environment variables (never hardcode secrets).
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// 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.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential());
|
||||
AIAgent writer = projectClient.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are an excellent slogan writer. Create one short slogan from the given brief.",
|
||||
name: "writer");
|
||||
|
||||
// Workflow shape: a brief adapter turns the Responses input into the writer's
|
||||
// prompt and drives the agent turn, then a formatter renders the writer's output as a single slogan line.
|
||||
// A factory builds a fresh workflow instance per run so independent sessions can run concurrently.
|
||||
static Workflow BuildWorkflow(AIAgent writer)
|
||||
{
|
||||
var briefExecutor = new BriefExecutor();
|
||||
var formatterExecutor = new SloganFormatterExecutor();
|
||||
|
||||
return new WorkflowBuilder(briefExecutor)
|
||||
.AddEdge(briefExecutor, writer)
|
||||
.AddEdge(writer, formatterExecutor)
|
||||
.WithOutputFrom(formatterExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Optional shared execution state: the factory constructor builds a fresh workflow instance per run (the
|
||||
// default, cacheWorkflow: false, shown explicitly here), so independent sessions run in parallel — a single
|
||||
// shared instance cannot run concurrent turns. Pass cacheWorkflow: true instead to build the workflow once,
|
||||
// lazily on first use, and reuse it (a deferred, cached target that, like a shared instance, cannot run
|
||||
// concurrent turns). It is paired with an in-memory CheckpointManager and a per-session
|
||||
// sessionId -> CheckpointInfo head cursor so a session can resume from its last checkpoint; a resume rehydrates
|
||||
// a fresh instance from that shared checkpoint store.
|
||||
var state = new HostedWorkflowState(_ => new ValueTask<Workflow>(BuildWorkflow(writer)), cacheWorkflow: false);
|
||||
|
||||
// The app keeps a response-id -> workflow-session-id cursor. previous_response_id rotates each turn, so every id in
|
||||
// a conversation's chain maps to the same workflow session, and resuming any of them restores that session's
|
||||
// latest checkpoint. In-memory for this local sample; a real app persists this per tenant/user.
|
||||
var responseToSession = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// The application owns this route. Binding the body as JsonElement lets ASP.NET Core deserialize the JSON
|
||||
// request body directly, so there is no JsonDocument to own or dispose.
|
||||
app.MapPost("/responses", async (JsonElement body, CancellationToken cancellationToken) =>
|
||||
{
|
||||
OpenAIResponsesRunRequest run;
|
||||
try
|
||||
{
|
||||
run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
// This sample supports previous_response_id continuation only, read off the already-parsed request.
|
||||
// A conversation id is not implemented here, so reject it. The candidate is untrusted: a real app
|
||||
// authenticates the caller and authorizes/binds it before use.
|
||||
if (run.ConversationId is not null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "This server supports previous_response_id continuation only; conversation is not implemented.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
string? previousResponseId = run.PreviousResponseId;
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
|
||||
// Resolve the workflow session: continue the chain's session when previous_response_id is known, otherwise
|
||||
// start a fresh workflow continuation.
|
||||
string sessionStoreId = previousResponseId is not null && responseToSession.TryGetValue(previousResponseId, out string? existing)
|
||||
? existing
|
||||
: Guid.NewGuid().ToString("N");
|
||||
|
||||
// Runs the workflow forward on the first call for this session, or restores the session's latest checkpoint
|
||||
// and runs forward with this turn's brief thereafter, then records the new head checkpoint.
|
||||
string brief = ExtractBrief(run.Messages);
|
||||
HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionStoreId, brief, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map this response id onto the workflow session so the next previous_response_id continues the same run.
|
||||
responseToSession[responseId] = sessionStoreId;
|
||||
|
||||
AgentResponse response = BuildWorkflowResponse(result);
|
||||
return Results.Json(OpenAIResponses.WriteResponse(response, responseId, previousResponseId));
|
||||
});
|
||||
|
||||
// Bind to a fixed local URL so the paired client sample has a deterministic default.
|
||||
// Override with the ASPNETCORE_URLS environment variable when needed.
|
||||
app.Run("http://localhost:5001");
|
||||
|
||||
// Flattens the Responses input messages into a single brief string for the workflow's start executor.
|
||||
static string ExtractBrief(IEnumerable<ChatMessage> messages)
|
||||
=> string.Join("\n", messages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t))).Trim();
|
||||
|
||||
// Extracts the workflow's final string output (the formatted slogan) from its output events, falling back to a
|
||||
// short run summary when the workflow emitted no string output this turn.
|
||||
static AgentResponse BuildWorkflowResponse(HostedWorkflowRunResult result)
|
||||
{
|
||||
string? slogan = null;
|
||||
foreach (WorkflowEvent evt in result.Events)
|
||||
{
|
||||
if (evt is WorkflowOutputEvent output && output.Data is string text && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
slogan = text;
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentResponse(new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
slogan ?? $"{result.Events.Count} workflow event(s) processed."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the Responses brief into the writer agent's turn. It builds the writer prompt from the brief (a plain
|
||||
/// topic string or a JSON object with topic/style/audience), sends it as a user message, and emits the
|
||||
/// <see cref="TurnToken"/> that drives the downstream agent. This keeps the workflow non-chat-protocol (its
|
||||
/// output is a plain string) while still driving the agent.
|
||||
/// </summary>
|
||||
[SendsMessage(typeof(ChatMessage))]
|
||||
[SendsMessage(typeof(TurnToken))]
|
||||
internal sealed class BriefExecutor() : Executor<string>("brief")
|
||||
{
|
||||
public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string topic = message.Trim();
|
||||
string style = "modern";
|
||||
string audience = "general";
|
||||
|
||||
if (topic.StartsWith('{'))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(topic);
|
||||
JsonElement root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("topic", out JsonElement topicElement))
|
||||
{
|
||||
topic = topicElement.GetString() ?? topic;
|
||||
style = root.TryGetProperty("style", out JsonElement styleElement) ? styleElement.GetString() ?? style : style;
|
||||
audience = root.TryGetProperty("audience", out JsonElement audienceElement) ? audienceElement.GetString() ?? audience : audience;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a JSON brief; treat the whole text as the topic.
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
{
|
||||
topic = "a generic product";
|
||||
}
|
||||
|
||||
string prompt =
|
||||
$"Topic: {topic}\n" +
|
||||
$"Style: {style}\n" +
|
||||
$"Audience: {audience}\n\n" +
|
||||
"Write a single short slogan that fits the topic, style, and audience.";
|
||||
|
||||
await context.SendMessageAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the writer agent's output as the workflow's final response: one terminal-friendly slogan line.
|
||||
/// </summary>
|
||||
internal sealed class SloganFormatterExecutor() : Executor<List<ChatMessage>, string>("terminal_formatter")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string slogan = string.Join("\n", message.Select(m => m.Text ?? string.Empty)).Trim().Trim('"');
|
||||
return ValueTask.FromResult($"Slogan: \"{slogan}\"");
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# Server (Hosting Responses Workflow)
|
||||
|
||||
Server half of the [Hosting Responses Workflow](../README.md) sample.
|
||||
|
||||
Exposes a workflow over the OpenAI Responses protocol on a `POST /responses` route you write. The workflow
|
||||
is a brief adapter, a slogan-writer agent, and a formatter that renders one slogan line. It uses the
|
||||
`OpenAIResponses` conversion helpers for the wire protocol and `HostedWorkflowState` for per-session
|
||||
checkpoint resume.
|
||||
|
||||
This server supports **`previous_response_id` continuation only** and **rejects `conversation` continuity
|
||||
with HTTP 400**. Because `previous_response_id` rotates every turn, the app owns a cursor that maps each
|
||||
response id to the stable workflow session id, so the whole rotating chain resumes the same checkpointed
|
||||
run. The first turn runs the workflow forward; later turns restore the latest checkpoint and run forward
|
||||
with the new brief. Binds to `http://localhost:5001` (override with `ASPNETCORE_URLS`).
|
||||
|
||||
`HostedWorkflowState` is constructed with a **workflow factory** and `cacheWorkflow: false` (the default,
|
||||
shown explicitly), so it builds a fresh workflow instance for every run. This lets independent sessions run
|
||||
concurrently — a single shared `Workflow` instance permits only one active run, so the instance constructor
|
||||
cannot process turns concurrently. A resume rehydrates a fresh instance from the session's checkpoint in the
|
||||
shared `CheckpointManager`, so per-run instances still continue the same run. (Passing `cacheWorkflow: true`
|
||||
would instead build the workflow once, lazily, and reuse it — a deferred, cached target that, like a shared
|
||||
instance, cannot run concurrent turns.) Concurrent turns against the *same* session id are the application's
|
||||
responsibility; a production app owns that per-session single-writer coordination.
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
|
||||
export FOUNDRY_MODEL="gpt-5.4-mini"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Call it directly, following the response-id chain across turns (the second call continues the first):
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5001/responses -H "content-type: application/json" \
|
||||
-d '{ "input": "{\"topic\": \"electric SUV\", \"style\": \"playful\", \"audience\": \"young families\"}" }'
|
||||
|
||||
# Take the "id" (resp_...) from the response above and pass it as previous_response_id:
|
||||
curl -s http://localhost:5001/responses -H "content-type: application/json" \
|
||||
-d '{ "input": "Make it a little more premium.", "previous_response_id": "resp_..." }'
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -30,7 +30,7 @@ dotnet/samples/
|
||||
│ │ └── openai/ # OpenAI provider samples
|
||||
│ ├── AgentOpenTelemetry/ # OpenTelemetry integration
|
||||
│ ├── AgentSkills/ # Agent skills patterns
|
||||
│ ├── AgentWithMemory/ # Memory providers (chat history, Mem0, Valkey, Foundry, AgentMemory)
|
||||
│ ├── AgentWithMemory/ # Memory providers (chat history, Mem0, Foundry)
|
||||
│ ├── AgentWithRAG/ # RAG patterns (text, vector store, Foundry)
|
||||
│ ├── AGUI/ # AG-UI protocol samples
|
||||
│ ├── DeclarativeAgents/ # Declarative agent definitions
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.Core.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -39,27 +38,8 @@ internal static class ActivityProcessor
|
||||
private static ChatMessage CreateChatMessageFromActivity(IActivity activity, IEnumerable<AIContent> messageContent) =>
|
||||
new(ChatRole.Assistant, [.. messageContent])
|
||||
{
|
||||
AdditionalProperties = MapAdditionalProperties(activity),
|
||||
AuthorName = activity.From?.Name,
|
||||
CreatedAt = activity.Timestamp,
|
||||
MessageId = activity.Id,
|
||||
RawRepresentation = activity
|
||||
};
|
||||
|
||||
private static AdditionalPropertiesDictionary? MapAdditionalProperties(IActivity activity)
|
||||
{
|
||||
IDictionary<string, JsonElement>? properties = activity.Properties;
|
||||
if (properties is null || properties.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var additionalProperties = new AdditionalPropertiesDictionary();
|
||||
foreach (KeyValuePair<string, JsonElement> property in properties)
|
||||
{
|
||||
additionalProperties[property.Key] = property.Value;
|
||||
}
|
||||
|
||||
return additionalProperties;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -99,7 +98,14 @@ public class CopilotStudioAgent : AIAgent
|
||||
responseMessagesList.Add(message);
|
||||
}
|
||||
|
||||
return CreateAgentResponse(responseMessagesList, this.Id);
|
||||
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
|
||||
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
|
||||
// so that they can tell things like response boundaries.
|
||||
return new AgentResponse(responseMessagesList)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = responseMessagesList.LastOrDefault()?.MessageId,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -126,113 +132,24 @@ public class CopilotStudioAgent : AIAgent
|
||||
string question = string.Join("\n", messages.Select(m => m.Text));
|
||||
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedSession.ConversationId, cancellationToken), streaming: true, this._logger);
|
||||
|
||||
await foreach (AgentResponseUpdate update in CreateAgentResponseUpdatesAsync(responseMessages, this.Id, cancellationToken).ConfigureAwait(false))
|
||||
// Enumerate the response messages
|
||||
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
|
||||
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
|
||||
// so that they can tell things like response boundaries.
|
||||
yield return new AgentResponseUpdate(message.Role, message.Contents)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
AdditionalProperties = message.AdditionalProperties,
|
||||
AuthorName = message.AuthorName,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
ResponseId = message.MessageId,
|
||||
MessageId = message.MessageId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="AgentResponse"/> from the messages returned by the Copilot Studio agent,
|
||||
/// populating the response-level metadata (such as <see cref="AgentResponse.CreatedAt"/>,
|
||||
/// <see cref="AgentResponse.FinishReason"/> and <see cref="AgentResponse.RawRepresentation"/>) from the
|
||||
/// final message so that consumers see the same surface as other <see cref="AIAgent"/> implementations.
|
||||
/// </summary>
|
||||
internal static AgentResponse CreateAgentResponse(IList<ChatMessage> messages, string? agentId)
|
||||
{
|
||||
ChatMessage? lastMessage = messages.Count > 0 ? messages[messages.Count - 1] : null;
|
||||
|
||||
return new AgentResponse(messages)
|
||||
{
|
||||
AgentId = agentId,
|
||||
ResponseId = lastMessage?.MessageId,
|
||||
CreatedAt = lastMessage?.CreatedAt,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = lastMessage?.RawRepresentation,
|
||||
AdditionalProperties = lastMessage?.AdditionalProperties,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the streamed <see cref="ChatMessage"/> sequence onto <see cref="AgentResponseUpdate"/> instances,
|
||||
/// carrying per-update metadata and setting <see cref="AgentResponseUpdate.FinishReason"/> only on the terminal
|
||||
/// update so streaming consumers can detect the response boundary.
|
||||
/// </summary>
|
||||
internal static async IAsyncEnumerable<AgentResponseUpdate> CreateAgentResponseUpdatesAsync(
|
||||
IAsyncEnumerable<ChatMessage> messages,
|
||||
string? agentId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Buffer a single message so we know which update is the terminal one (it carries the finish reason).
|
||||
// Manual enumeration lets us still emit any already-received content if the source faults mid-stream,
|
||||
// preserving the original streaming behavior, before re-throwing the original exception.
|
||||
ChatMessage? pending = null;
|
||||
ExceptionDispatchInfo? failure = null;
|
||||
|
||||
IAsyncEnumerator<ChatMessage> enumerator = messages.GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
bool moved;
|
||||
try
|
||||
{
|
||||
moved = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ExceptionDispatchInfo.Capture(ex);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!moved)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (pending is not null)
|
||||
{
|
||||
yield return CreateAgentResponseUpdate(pending, agentId, finishReason: null);
|
||||
}
|
||||
|
||||
pending = enumerator.Current;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch when (failure is not null)
|
||||
{
|
||||
// A fault was already captured from the stream; don't let a disposal
|
||||
// exception override the original streaming exception.
|
||||
}
|
||||
}
|
||||
|
||||
if (pending is not null)
|
||||
{
|
||||
// The last received message is the terminal update only when the stream completed successfully.
|
||||
yield return CreateAgentResponseUpdate(pending, agentId, finishReason: failure is null ? ChatFinishReason.Stop : null);
|
||||
}
|
||||
|
||||
failure?.Throw();
|
||||
}
|
||||
|
||||
private static AgentResponseUpdate CreateAgentResponseUpdate(ChatMessage message, string? agentId, ChatFinishReason? finishReason) =>
|
||||
new(message.Role, message.Contents)
|
||||
{
|
||||
AgentId = agentId,
|
||||
AdditionalProperties = message.AdditionalProperties,
|
||||
AuthorName = message.AuthorName,
|
||||
CreatedAt = message.CreatedAt,
|
||||
FinishReason = finishReason,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
ResponseId = message.MessageId,
|
||||
MessageId = message.MessageId,
|
||||
};
|
||||
|
||||
private async Task<string> StartNewConversationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string? conversationId = null;
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
<PackageReference Include="Microsoft.Agents.CopilotStudio.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Copilot Studio</Title>
|
||||
|
||||
+1
-7
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
@@ -14,12 +14,6 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for creating a <see cref="HarnessAgent"/> from an <see cref="IChatClient"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class ChatClientHarnessExtensions
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -46,6 +51,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="FileAccessProvider"/> — shared file access providing read/write tools for a working directory. Enable by setting <see cref="HarnessAgentOptions.FileAccessStore"/>; configure via <see cref="HarnessAgentOptions.FileAccessProviderOptions"/>.</description></item>
|
||||
/// <item><description><see cref="BackgroundAgentsProvider"/> — enables delegation to background agents for parallel work. Enable by setting <see cref="HarnessAgentOptions.BackgroundAgents"/>.</description></item>
|
||||
/// <item><description><c>ShellEnvironmentProvider</c> — injects OS/shell/CWD information and a shell execution tool. Enable by setting <c>HarnessAgentOptions.ShellExecutor</c> (.NET only).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -74,6 +80,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// and combined with agent-specific instructions via <see cref="ChatOptions.Instructions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class HarnessAgent : DelegatingAIAgent
|
||||
{
|
||||
/// <summary>
|
||||
@@ -215,15 +222,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
// Build ChatClient stack
|
||||
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
|
||||
|
||||
// Registered first so it sits as the outermost decorator, above the approval-not-required bypassing
|
||||
// and function invocation middleware, so it can bind inbound approval responses to the requests the
|
||||
// framework surfaced. The harness uses UseProvidedChatClientAsIs, so this is added manually here rather
|
||||
// than via the default ChatClientAgent pipeline.
|
||||
if (options?.DisableApprovalResponseBinding is not true)
|
||||
{
|
||||
chatClientBuilder.UseApprovalResponseBinding();
|
||||
}
|
||||
|
||||
if (options?.DisableApprovalNotRequiredFunctionBypassing is not true)
|
||||
{
|
||||
chatClientBuilder.UseApprovalNotRequiredFunctionBypassing();
|
||||
@@ -281,6 +279,16 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
result.Tools.Add(new HostedWebSearchTool());
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
result.Tools ??= [];
|
||||
result.Tools.Add(options.ShellToolName is { } shellToolName
|
||||
? shellExecutor.AsAIFunction(shellToolName, options.ShellToolDescription, !options.DisableShellToolApproval)
|
||||
: shellExecutor.AsAIFunction(description: options.ShellToolDescription, requireApproval: !options.DisableShellToolApproval));
|
||||
}
|
||||
#endif
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -335,6 +343,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
}
|
||||
}
|
||||
|
||||
#if NET
|
||||
if (options?.ShellExecutor is ShellExecutor shellExecutor)
|
||||
{
|
||||
providers.Add(new ShellEnvironmentProvider(shellExecutor, options.ShellEnvironmentProviderOptions));
|
||||
}
|
||||
#endif
|
||||
|
||||
if (options?.AIContextProviders is IEnumerable<AIContextProvider> userProviders)
|
||||
{
|
||||
providers.AddRange(userProviders);
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
#if NET
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
@@ -11,6 +14,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Represents configuration options for a <see cref="HarnessAgent"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class HarnessAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -42,7 +46,6 @@ public sealed class HarnessAgentOptions
|
||||
/// <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public int? MaxContextWindowTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -59,7 +62,6 @@ public sealed class HarnessAgentOptions
|
||||
/// is provided and <see cref="DisableCompaction"/> is <see langword="false"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -79,7 +81,6 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is ignored when <see cref="DisableCompaction"/> is <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public CompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -91,7 +92,6 @@ public sealed class HarnessAgentOptions
|
||||
/// <see cref="CompactionProvider"/> is added to the chat client pipeline, and the default
|
||||
/// <see cref="InMemoryChatHistoryProvider"/> is configured without a chat reducer.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool DisableCompaction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -162,7 +162,6 @@ public sealed class HarnessAgentOptions
|
||||
/// as a single-shot agent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public IEnumerable<LoopEvaluator>? LoopEvaluators { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -172,7 +171,6 @@ public sealed class HarnessAgentOptions
|
||||
/// When <see langword="null"/>, the <see cref="LoopAgent"/> uses its default settings. This property is ignored
|
||||
/// when <see cref="LoopEvaluators"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public LoopAgentOptions? LoopAgentOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -218,19 +216,6 @@ public sealed class HarnessAgentOptions
|
||||
/// </remarks>
|
||||
public bool DisableApprovalNotRequiredFunctionBypassing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether binding inbound tool-approval responses to the
|
||||
/// model-originated approval requests that the framework surfaced is disabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
|
||||
/// added by <see cref="ChatClientBuilderExtensions.UseApprovalResponseBinding"/> as the outermost decorator
|
||||
/// above the function invocation middleware. It records each surfaced approval request and, on the next
|
||||
/// request, binds every approval response to its recorded request so an approved call matches exactly what
|
||||
/// was surfaced for approval.
|
||||
/// </remarks>
|
||||
public bool DisableApprovalResponseBinding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
|
||||
/// </summary>
|
||||
@@ -249,7 +234,6 @@ public sealed class HarnessAgentOptions
|
||||
/// a default <see cref="FileSystemAgentFileStore"/> is created.
|
||||
/// This property is ignored when <see cref="DisableFileMemory"/> is <see langword="true"/>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public AgentFileStore? FileMemoryStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -261,7 +245,6 @@ public sealed class HarnessAgentOptions
|
||||
/// included in the agent's context providers, backed by the supplied store and configured with
|
||||
/// <see cref="FileAccessProviderOptions"/> when provided.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public AgentFileStore? FileAccessStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -271,7 +254,6 @@ public sealed class HarnessAgentOptions
|
||||
/// This property is only used when <see cref="FileAccessStore"/> is set (file access is opt-in).
|
||||
/// When <see langword="null"/>, the provider uses its default options.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public FileAccessProviderOptions? FileAccessProviderOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -366,7 +348,6 @@ public sealed class HarnessAgentOptions
|
||||
/// (case-insensitive). If these requirements are not met, <see cref="BackgroundAgentsProvider"/> will throw
|
||||
/// an <see cref="System.ArgumentException"/> during construction.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public IEnumerable<AIAgent>? BackgroundAgents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -376,6 +357,76 @@ public sealed class HarnessAgentOptions
|
||||
/// Use this to customize instructions or agent list formatting for the background agents feature.
|
||||
/// This property is ignored when <see cref="BackgroundAgents"/> is <see langword="null"/> or empty.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public BackgroundAgentsProviderOptions? BackgroundAgentsProviderOptions { get; set; }
|
||||
|
||||
#if NET
|
||||
/// <summary>
|
||||
/// Gets or sets the shell executor used to enable shell tool and environment probing via <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-null, a <see cref="ShellEnvironmentProvider"/> is automatically included in the agent's context
|
||||
/// providers (injecting OS/shell/CWD information into the system prompt), and the executor's
|
||||
/// <see cref="ShellExecutor.AsAIFunction"/> is registered as a callable tool.
|
||||
/// When <see langword="null"/> (the default), no shell features are enabled.
|
||||
/// </remarks>
|
||||
public ShellExecutor? ShellExecutor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the shell execution tool exposed to the model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <see langword="null"/> (the default), the shell executor's default tool name (<c>run_shell</c>) is used.
|
||||
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Security warning:</b> auto-approval rules may match tool calls solely by name. Pay attention to
|
||||
/// the tool names approved by auto-approval rules for other features. Setting this property to a
|
||||
/// value that collides with a tool name that is approved by an auto-approval rule for another feature will cause
|
||||
/// the shell tool to also be auto-approved, bypassing the human approval boundary. Choose a unique
|
||||
/// name that no other registered tool uses.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? ShellToolName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description of the shell execution tool shown to the model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/> (the default), the shell executor's built-in description is used.
|
||||
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </remarks>
|
||||
public string? ShellToolDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether approval is disabled for the shell execution tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <see langword="false"/> (the default), the shell tool is wrapped in an <see cref="ApprovalRequiredAIFunction"/>
|
||||
/// so every command requires explicit approval before executing. When <see langword="true"/>, the tool can be invoked
|
||||
/// without approval. This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting this to <see langword="true"/> also requires the underlying <see cref="ShellExecutor"/> to permit
|
||||
/// unapproved use. The inverse of this value is forwarded as the <c>requireApproval</c> argument to
|
||||
/// <see cref="ShellExecutor.AsAIFunction"/>, and some executors enforce their own security boundary:
|
||||
/// <see cref="LocalShellExecutor"/> throws an <see cref="System.InvalidOperationException"/> unless it was
|
||||
/// constructed with <see cref="LocalShellExecutorOptions.AcknowledgeUnsafe"/> set to <see langword="true"/>,
|
||||
/// because running unapproved commands directly on the host is inherently unsafe. Sandboxed executors such as
|
||||
/// <see cref="DockerShellExecutor"/> impose no such requirement.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool DisableShellToolApproval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets optional configuration for the <see cref="ShellEnvironmentProvider"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this to customize which tools are probed, the probe timeout, shell family override,
|
||||
/// or the instructions formatter.
|
||||
/// This property is ignored when <see cref="ShellExecutor"/> is <see langword="null"/>.
|
||||
/// </remarks>
|
||||
public ShellEnvironmentProviderOptions? ShellEnvironmentProviderOptions { get; set; }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<IsReleaseCandidate>false</IsReleaseCandidate>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Harness</Title>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// Side-effect-free helpers that convert between the OpenAI Responses wire protocol and Agent Framework
|
||||
/// run values, for applications that own their own HTTP route, authentication, middleware, and storage.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These helpers are the app-owned-routing counterpart to <c>MapOpenAIResponses</c>.
|
||||
/// <c>MapOpenAIResponses</c> owns routing and storage; these helpers let an application own those concerns
|
||||
/// and reuse only the protocol conversion. Both share the same internal conversion logic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust boundary.</strong> <see cref="GetSessionStoreId(OpenAIResponsesRunRequest)"/> returns an
|
||||
/// untrusted candidate continuation key. The application must authenticate the caller and authorize/bind the
|
||||
/// id to the authenticated principal before using it as a session or checkpoint key. The helpers never
|
||||
/// perform I/O.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class OpenAIResponses
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an OpenAI Responses request body into Agent Framework run values (messages and options).
|
||||
/// </summary>
|
||||
/// <param name="body">The OpenAI Responses-shaped request body.</param>
|
||||
/// <param name="mapOptions">
|
||||
/// Optional options controlling how request settings are mapped onto the run. By default no request
|
||||
/// setting is mapped onto the run.
|
||||
/// </param>
|
||||
/// <returns>The parsed messages and mapped run options.</returns>
|
||||
/// <exception cref="ArgumentException">The body could not be parsed as an OpenAI Responses request.</exception>
|
||||
/// <exception cref="NotSupportedException">A request setting is not supported by the configured mapping.</exception>
|
||||
public static OpenAIResponsesRunRequest ToAgentRunRequest(JsonElement body, OpenAIResponsesMapOptions? mapOptions = null)
|
||||
{
|
||||
CreateResponse request;
|
||||
try
|
||||
{
|
||||
request = body.Deserialize(OpenAIHostingJsonContext.Default.CreateResponse)
|
||||
?? throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ArgumentException("The request body could not be parsed as an OpenAI Responses request.", nameof(body), ex);
|
||||
}
|
||||
|
||||
if (request.Input is null)
|
||||
{
|
||||
throw new ArgumentException("The request body is missing the required 'input' field.", nameof(body));
|
||||
}
|
||||
|
||||
AgentRunOptions? options = (mapOptions ?? new OpenAIResponsesMapOptions()).RunOptionsFactory(request.ToRequestInfo());
|
||||
|
||||
var messages = new List<ChatMessage>();
|
||||
foreach (InputMessage inputMessage in request.Input.GetInputMessages())
|
||||
{
|
||||
messages.Add(inputMessage.ToChatMessage());
|
||||
}
|
||||
|
||||
return new OpenAIResponsesRunRequest(messages, options, request.PreviousResponseId, request.Conversation?.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a final <see cref="AgentResponse"/> into an OpenAI Responses-shaped payload.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response to render.</param>
|
||||
/// <param name="responseId">The id to assign to the rendered response (see <see cref="CreateResponseId"/>).</param>
|
||||
/// <param name="conversationId">
|
||||
/// The optional conversation id to surface on the rendered response.
|
||||
/// </param>
|
||||
/// <returns>An OpenAI Responses-shaped <see cref="JsonElement"/>.</returns>
|
||||
public static JsonElement WriteResponse(AgentResponse response, string responseId, string? conversationId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentException.ThrowIfNullOrEmpty(responseId);
|
||||
|
||||
AgentInvocationContext context = CreateContext(responseId, conversationId);
|
||||
Response wire = response.ToResponse(EmptyRequest(), context);
|
||||
return JsonSerializer.SerializeToElement(wire, OpenAIHostingJsonContext.Default.Response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a stream of <see cref="AgentResponseUpdate"/> into OpenAI Responses Server-Sent-Event frames.
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent streaming updates.</param>
|
||||
/// <param name="responseId">The id to assign to the rendered response (see <see cref="CreateResponseId"/>).</param>
|
||||
/// <param name="conversationId">The optional conversation id to surface on the rendered response.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An async sequence of SSE frame strings, each terminated by a blank line.</returns>
|
||||
public static async IAsyncEnumerable<string> WriteResponseStreamAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
string responseId,
|
||||
string? conversationId = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(updates);
|
||||
ArgumentException.ThrowIfNullOrEmpty(responseId);
|
||||
|
||||
AgentInvocationContext context = CreateContext(responseId, conversationId);
|
||||
await foreach (StreamingResponseEvent streamingEvent in updates
|
||||
.ToStreamingResponseAsync(EmptyRequest(), context, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
string json = JsonSerializer.Serialize(streamingEvent, OpenAIHostingJsonContext.Default.StreamingResponseEvent);
|
||||
yield return $"event: {streamingEvent.Type}\ndata: {json}\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the id under which the session should be stored from an already-parsed
|
||||
/// <see cref="OpenAIResponsesRunRequest"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The parsed request produced by <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/>.</param>
|
||||
/// <returns>
|
||||
/// The <c>previous_response_id</c> when present; otherwise the <c>conversation</c> id when present;
|
||||
/// otherwise <see langword="null"/> when the request carries neither.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// This reads the ids off the already-parsed request rather than re-parsing the body, so an application
|
||||
/// calls <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/> once and then this. It is kept
|
||||
/// separate so the trust boundary stays visible: using a request-derived key is an explicit application
|
||||
/// decision, and the returned value is an <strong>untrusted candidate key</strong> until the application has
|
||||
/// authorized it for the caller. A <see langword="null"/> result means only that the request carried no
|
||||
/// continuation id (unparseable bodies are already rejected by <see cref="ToAgentRunRequest(JsonElement, OpenAIResponsesMapOptions?)"/>).
|
||||
/// <para>
|
||||
/// The Responses protocol treats <c>previous_response_id</c> and <c>conversation</c> as mutually exclusive; if a
|
||||
/// request carries both, this helper prefers <c>previous_response_id</c> (the response-chain pointer). Note that
|
||||
/// <c>previous_response_id</c> changes each turn and is therefore not a stable partition key; use
|
||||
/// <see cref="OpenAIResponsesRunRequest.ConversationId"/> when a stable key is required (for example a workflow
|
||||
/// checkpoint cursor key).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string? GetSessionStoreId(OpenAIResponsesRunRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request.PreviousResponseId ?? request.ConversationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpenAI Responses-shaped response id (a <c>resp_*</c> value).
|
||||
/// </summary>
|
||||
/// <returns>A new response id.</returns>
|
||||
public static string CreateResponseId() => IdGenerator.NewId("resp");
|
||||
|
||||
private static AgentInvocationContext CreateContext(string responseId, string? conversationId)
|
||||
=> new(new IdGenerator(responseId, conversationId));
|
||||
|
||||
// The rendering converters never read the request input; a minimal request lets the facade render
|
||||
// a response without requiring the caller to supply the originating request object.
|
||||
private static CreateResponse EmptyRequest() => new() { Input = ResponseInput.FromText(string.Empty) };
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// The result of converting an OpenAI Responses request body into Agent Framework run values via
|
||||
/// <see cref="OpenAIResponses.ToAgentRunRequest(System.Text.Json.JsonElement, OpenAIResponsesMapOptions?)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This type carries the values an application passes to <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>
|
||||
/// (or the streaming equivalent) when it owns its own hosting route. It does not run the agent; the
|
||||
/// application remains in control of when and how the run happens.
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesRunRequest
|
||||
{
|
||||
internal OpenAIResponsesRunRequest(IList<ChatMessage> messages, AgentRunOptions? options, string? previousResponseId = null, string? conversationId = null)
|
||||
{
|
||||
this.Messages = messages;
|
||||
this.Options = options;
|
||||
this.PreviousResponseId = previousResponseId;
|
||||
this.ConversationId = conversationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat messages parsed from the request body, ready to pass to an <see cref="AIAgent"/> run.
|
||||
/// </summary>
|
||||
public IList<ChatMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run options mapped from the request, or <see langword="null"/> when no request setting is
|
||||
/// mapped onto the run. The mapping is controlled by <see cref="OpenAIResponsesMapOptions.RunOptionsFactory"/>;
|
||||
/// by default no request setting is mapped.
|
||||
/// </summary>
|
||||
public AgentRunOptions? Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request's <c>previous_response_id</c> continuation pointer, or <see langword="null"/> when absent.
|
||||
/// This changes each turn (it follows the response chain), so it is not a stable partition key.
|
||||
/// </summary>
|
||||
public string? PreviousResponseId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request's <c>conversation</c> id, or <see langword="null"/> when absent. Unlike
|
||||
/// <see cref="PreviousResponseId"/>, this is stable across turns, so it is a valid stable partition key.
|
||||
/// </summary>
|
||||
public string? ConversationId { get; }
|
||||
}
|
||||
@@ -17,17 +17,17 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// or different service instances in hosted scenarios.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust model.</strong> The <c>sessionStoreId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> is the id under which the session is
|
||||
/// stored. It typically originates from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an
|
||||
/// A2A <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, sessionStoreId)</c> tuple carries no principal/owner
|
||||
/// <strong>Trust model.</strong> The <c>conversationId</c> passed to
|
||||
/// <see cref="GetSessionAsync"/> and <see cref="SaveSessionAsync"/> typically originates
|
||||
/// from the wire (for example, an AG-UI <c>RunAgentInput.ThreadId</c> or an A2A
|
||||
/// <c>contextId</c>). It is a chain-resume identifier, <em>not</em> an authorization
|
||||
/// token, and the <c>(agent, conversationId)</c> tuple carries no principal/owner
|
||||
/// dimension. Hosts that serve more than one user from the same registered store must
|
||||
/// therefore compose a principal dimension into the lookup key, otherwise any caller
|
||||
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
|
||||
/// who knows or guesses another caller's <c>conversationId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>sessionStoreId</c> to include an isolation key resolved from a
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
|
||||
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
|
||||
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
|
||||
@@ -36,12 +36,12 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Implementer guidance.</strong> Implementations should treat
|
||||
/// <c>sessionStoreId</c> as opaque: do not parse it, do not impose length
|
||||
/// <c>conversationId</c> as opaque: do not parse it, do not impose length
|
||||
/// or character-set constraints on it, and do not assume it round-trips to the value
|
||||
/// the caller originally supplied (decorators such as
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>sessionStoreId</c> will also surface the isolation prefix when a
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// scoping decorator is in the chain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -51,13 +51,13 @@ public abstract class AgentSessionStore
|
||||
/// Saves a serialized agent session to persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session.</param>
|
||||
/// <param name="session">The session to save.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous save operation.</returns>
|
||||
public abstract ValueTask SaveSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
string conversationId,
|
||||
AgentSession session,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -65,44 +65,15 @@ public abstract class AgentSessionStore
|
||||
/// Retrieves a serialized agent session from persistent storage.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</param>
|
||||
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous retrieval operation. The task result contains the
|
||||
/// restored <see cref="AgentSession"/>, or a newly created session when nothing is stored for the id.
|
||||
/// A task that represents the asynchronous retrieval operation.
|
||||
/// The task result contains the serialized session state, or <see langword="null"/> if not found.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <strong>Isolation.</strong> Each call must return an <em>independent</em> <see cref="AgentSession"/>
|
||||
/// instance. Callers may mutate the returned session, and may run several concurrent branches from the
|
||||
/// same <paramref name="sessionStoreId"/> (for example forking from an OpenAI Responses
|
||||
/// <c>previous_response_id</c>), without those branches observing one another's mutations or altering the
|
||||
/// stored state. The in-box stores satisfy this by returning a fresh instance rehydrated from a serialized
|
||||
/// snapshot on every call; implementations that cache a live <see cref="AgentSession"/> must return an
|
||||
/// independent copy (for example by round-tripping through
|
||||
/// <see cref="AIAgent.SerializeSessionAsync(AgentSession, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>
|
||||
/// and <see cref="AIAgent.DeserializeSessionAsync(System.Text.Json.JsonElement, System.Text.Json.JsonSerializerOptions?, CancellationToken)"/>)
|
||||
/// rather than handing back the shared instance.
|
||||
/// </remarks>
|
||||
public abstract ValueTask<AgentSession> GetSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a stored agent session, if present.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent that owns this session.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous delete operation.</returns>
|
||||
/// <remarks>
|
||||
/// Implementations that support removal delete the session and treat a missing session as a no-op.
|
||||
/// Implementations that genuinely cannot support deletion should throw <see cref="NotSupportedException"/>.
|
||||
/// </remarks>
|
||||
/// <exception cref="NotSupportedException">The store does not support deletion.</exception>
|
||||
public abstract ValueTask DeleteSessionAsync(
|
||||
AIAgent agent,
|
||||
string sessionStoreId,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
|
||||
@@ -53,16 +53,12 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken);
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, sessionStoreId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken);
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// The result of a <see cref="HostedWorkflowState"/> run or resume.
|
||||
/// </summary>
|
||||
public sealed class HostedWorkflowRunResult
|
||||
{
|
||||
internal HostedWorkflowRunResult(string sessionId, IReadOnlyList<Workflows.WorkflowEvent> events, Workflows.CheckpointInfo? checkpoint)
|
||||
{
|
||||
this.SessionId = sessionId;
|
||||
this.Events = events;
|
||||
this.Checkpoint = checkpoint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the application-selected session id this run was executed under.
|
||||
/// </summary>
|
||||
public string SessionId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow events emitted during this run.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Workflows.WorkflowEvent> Events { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the head checkpoint recorded for the session after this run, or <see langword="null"/> when
|
||||
/// checkpointing produced no checkpoint.
|
||||
/// </summary>
|
||||
public Workflows.CheckpointInfo? Checkpoint { get; }
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Optional shared execution state for applications that own their own hosting route and want to expose a
|
||||
/// workflow with per-session checkpoint resume. Pairs a <see cref="Workflow"/> target with a
|
||||
/// <see cref="CheckpointManager"/> and an application-scoped <c>sessionId -> CheckpointInfo</c> head cursor.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The .NET workflow checkpoint store is already keyed by session id, but <see cref="CheckpointInfo"/> carries
|
||||
/// no ordering, so this holder remembers the head checkpoint per session to resume the correct one. It does not
|
||||
/// own routing, authentication, or storage policy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The in-memory head cursor accelerates the common case, but when it misses (for example a new holder or a
|
||||
/// process restart) the holder falls back to <see cref="CheckpointManager.GetLatestCheckpointAsync"/>. A durable
|
||||
/// <see cref="CheckpointManager"/> therefore resumes correctly across restarts; the default in-memory manager does
|
||||
/// not persist, so with it a restart starts the session fresh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Trust boundary.</strong> <c>sessionId</c> is an application-selected partition key. When it originates
|
||||
/// from the wire, the application must authenticate the caller and authorize the key before using it here. The
|
||||
/// checkpoint boundary must be at least as specific as the authorized session boundary.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostedWorkflowState
|
||||
{
|
||||
private readonly CheckpointManager _checkpointManager;
|
||||
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
|
||||
private readonly Workflow? _workflow;
|
||||
private readonly Func<CancellationToken, ValueTask<Workflow>>? _workflowFactory;
|
||||
// Cached-factory mode: the factory runs once, on first use, guarded by _cacheSync, and the built workflow task
|
||||
// is reused for every run thereafter.
|
||||
private readonly bool _cacheWorkflow;
|
||||
private readonly object _cacheSync = new();
|
||||
private Task<Workflow>? _cachedWorkflowTask;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ConcurrentDictionary<string, CheckpointInfo> _cursor = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedWorkflowState"/> class over a single shared workflow
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow target.</param>
|
||||
/// <param name="checkpointManager">
|
||||
/// The checkpoint manager to use. Defaults to <see cref="CheckpointManager.CreateInMemory"/> when not provided.
|
||||
/// </param>
|
||||
/// <param name="executionEnvironment">
|
||||
/// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment
|
||||
/// (<see cref="InProcessExecutionEnvironment"/>) configured with <paramref name="checkpointManager"/>. Supplying a
|
||||
/// custom environment (for example a future durable/out-of-process environment) is supported; the supplied
|
||||
/// environment must be configured to checkpoint into the same store as <paramref name="checkpointManager"/>, since
|
||||
/// the holder reads that manager directly to recover the head checkpoint when its in-memory cursor misses.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress).
|
||||
/// Defaults to <see cref="NullLoggerFactory"/> when not provided.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="workflow"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// A single workflow instance cannot be run by two runners at once, so concurrent runs against this holder are
|
||||
/// not supported; process turns one at a time. To run independent sessions concurrently, use the factory
|
||||
/// constructor
|
||||
/// (<see cref="HostedWorkflowState(Func{CancellationToken, ValueTask{Workflow}}, CheckpointManager?, IWorkflowExecutionEnvironment?, ILoggerFactory?, bool)"/>),
|
||||
/// which builds a fresh workflow instance per run.
|
||||
/// </remarks>
|
||||
public HostedWorkflowState(
|
||||
Workflow workflow,
|
||||
CheckpointManager? checkpointManager = null,
|
||||
IWorkflowExecutionEnvironment? executionEnvironment = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(workflow);
|
||||
|
||||
this._workflow = workflow;
|
||||
this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory();
|
||||
this._executionEnvironment = executionEnvironment ?? InProcessExecution.Default.WithCheckpointing(this._checkpointManager);
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HostedWorkflowState"/> class that builds its workflow from a
|
||||
/// factory.
|
||||
/// </summary>
|
||||
/// <param name="workflowFactory">
|
||||
/// A factory that produces a workflow instance. Every produced instance must have the same executor topology,
|
||||
/// because a resume rehydrates an instance from the session's checkpoint in the shared
|
||||
/// <paramref name="checkpointManager"/>. By default (<paramref name="cacheWorkflow"/> is <see langword="false"/>)
|
||||
/// it is invoked once per run, so independent sessions each get their own instance and run concurrently. When
|
||||
/// <paramref name="cacheWorkflow"/> is <see langword="true"/> it is invoked once, on first use, and the built
|
||||
/// instance is reused for every run.
|
||||
/// </param>
|
||||
/// <param name="checkpointManager">
|
||||
/// The checkpoint manager to use. Defaults to <see cref="CheckpointManager.CreateInMemory"/> when not provided.
|
||||
/// </param>
|
||||
/// <param name="executionEnvironment">
|
||||
/// The workflow execution environment used to run and resume the workflow. Defaults to an in-process environment
|
||||
/// (<see cref="InProcessExecutionEnvironment"/>) configured with <paramref name="checkpointManager"/>. A supplied
|
||||
/// environment must checkpoint into the same store as <paramref name="checkpointManager"/>, since the holder reads
|
||||
/// that manager directly to recover the head checkpoint when its in-memory cursor misses.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// The logger factory used to report resume diagnostics (for example, a resume turn that made no progress).
|
||||
/// Defaults to <see cref="NullLoggerFactory"/> when not provided.
|
||||
/// </param>
|
||||
/// <param name="cacheWorkflow">
|
||||
/// When <see langword="false"/> (the default), the factory is invoked once per run, so independent sessions run
|
||||
/// in parallel. When <see langword="true"/>, the factory is invoked once, lazily on first use, and the built
|
||||
/// workflow is cached and reused for every run, a deferred, cached target. Because that reuses a single
|
||||
/// instance (which cannot be run by two runners at once), a cached workflow's turns cannot run concurrently,
|
||||
/// exactly like the instance constructor. The cached build uses <see cref="CancellationToken.None"/> because the
|
||||
/// single built instance is shared across runs and must not be tied to one request's cancellation. If a cached
|
||||
/// build faults or is canceled, it is not reused: the next run starts a fresh build so a transient setup failure
|
||||
/// does not poison every later run.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="workflowFactory"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// With the default (uncached) factory, turns are not serialized: independent sessions run in parallel, and
|
||||
/// concurrent turns against the <em>same</em> session id are not serialized either — an application that needs a
|
||||
/// single writer per session owns that coordination.
|
||||
/// </remarks>
|
||||
public HostedWorkflowState(
|
||||
Func<CancellationToken, ValueTask<Workflow>> workflowFactory,
|
||||
CheckpointManager? checkpointManager = null,
|
||||
IWorkflowExecutionEnvironment? executionEnvironment = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
bool cacheWorkflow = false)
|
||||
{
|
||||
_ = Throw.IfNull(workflowFactory);
|
||||
|
||||
this._workflowFactory = workflowFactory;
|
||||
this._cacheWorkflow = cacheWorkflow;
|
||||
|
||||
this._checkpointManager = checkpointManager ?? CheckpointManager.CreateInMemory();
|
||||
this._executionEnvironment = executionEnvironment ?? InProcessExecution.Default.WithCheckpointing(this._checkpointManager);
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(typeof(HostedWorkflowState));
|
||||
}
|
||||
|
||||
// Resolves the workflow for a turn: the shared instance in instance mode; the cached instance in cached-factory
|
||||
// mode (built once on first use); or a fresh instance from the factory in the default (uncached) factory mode.
|
||||
private async ValueTask<Workflow> ResolveWorkflowAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._workflow is not null)
|
||||
{
|
||||
return this._workflow;
|
||||
}
|
||||
|
||||
if (!this._cacheWorkflow)
|
||||
{
|
||||
return await this._workflowFactory!(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Cached factory: build the workflow once. The lock guards only the one-time task assignment (and the
|
||||
// factory's synchronous prefix); the actual build is awaited outside the lock. CancellationToken.None is
|
||||
// used because the single built instance is shared across runs and must not be tied to one request.
|
||||
// A previously cached build that faulted or was canceled is not reused: the next call starts a fresh
|
||||
// build so a transient setup failure does not poison every later run with the same cached failure.
|
||||
Task<Workflow> buildTask;
|
||||
lock (this._cacheSync)
|
||||
{
|
||||
// Reuse the cached build only when it exists and has not faulted or been canceled; otherwise start a
|
||||
// fresh build so a transient setup failure does not poison every later run.
|
||||
if (this._cachedWorkflowTask is not { IsFaulted: false, IsCanceled: false })
|
||||
{
|
||||
this._cachedWorkflowTask = this._workflowFactory!(CancellationToken.None).AsTask();
|
||||
}
|
||||
|
||||
buildTask = this._cachedWorkflowTask;
|
||||
}
|
||||
|
||||
return await buildTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the workflow forward for <paramref name="sessionId"/> with checkpointing on the first turn, or, on
|
||||
/// subsequent turns, restores the session's recorded head checkpoint and then runs the workflow forward with
|
||||
/// the new turn's <paramref name="input"/>. The new head checkpoint is recorded for the session afterwards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The resume semantics restore then run: each turn restores the latest checkpoint to rehydrate accumulated
|
||||
/// workflow state and then applies the new input, rather than continuing a halted run with no input (which
|
||||
/// would leave the run waiting for input indefinitely). For agent (chat-protocol) workflows the new input is
|
||||
/// accompanied by a
|
||||
/// <see cref="TurnToken"/> so the turn is driven, matching the fresh-run path.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TInput">The workflow input type.</typeparam>
|
||||
/// <param name="sessionId">The application-selected session id.</param>
|
||||
/// <param name="input">The input to run on this turn (used both when starting a new run and when resuming).</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>The run result, including the events emitted on this turn and the recorded head checkpoint.</returns>
|
||||
public async ValueTask<HostedWorkflowRunResult> RunOrResumeAsync<TInput>(string sessionId, TInput input, CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
{
|
||||
_ = Throw.IfNullOrEmpty(sessionId);
|
||||
_ = Throw.IfNull(input);
|
||||
|
||||
return await this.RunOrResumeCoreAsync(sessionId, input, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<HostedWorkflowRunResult> RunOrResumeCoreAsync<TInput>(string sessionId, TInput input, CancellationToken cancellationToken)
|
||||
where TInput : notnull
|
||||
{
|
||||
Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head))
|
||||
{
|
||||
// The in-memory cursor is empty for this session. Fall back to the checkpoint manager so a durable
|
||||
// manager still resumes after the cursor is lost (for example a process restart or a new holder over
|
||||
// the same store).
|
||||
head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (head is null)
|
||||
{
|
||||
// First turn for this session: run the workflow forward from its start executor with the input.
|
||||
Run freshRun = await this._executionEnvironment.RunAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false);
|
||||
await using (freshRun.ConfigureAwait(false))
|
||||
{
|
||||
return this.Record(sessionId, freshRun.OutgoingEvents.ToList(), freshRun.LastCheckpoint);
|
||||
}
|
||||
}
|
||||
|
||||
// Subsequent turn: restore the session's latest checkpoint to rehydrate accumulated workflow state, then
|
||||
// run the workflow forward with the new turn's input. Agent workflows use the chat protocol, which requires
|
||||
// a TurnToken to drive the turn (mirroring how the fresh-run path seeds one).
|
||||
//
|
||||
// The streaming resume restores state without draining to a halt first; the non-streaming resume would
|
||||
// block waiting for input immediately after restore (before we can deliver the new input).
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
StreamingRun resumed = await this._executionEnvironment.ResumeStreamingAsync(workflow, head, cancellationToken).ConfigureAwait(false);
|
||||
await using (resumed.ConfigureAwait(false))
|
||||
{
|
||||
await resumed.TrySendMessageAsync(input).ConfigureAwait(false);
|
||||
if (descriptor.IsChatProtocol() && input is not TurnToken)
|
||||
{
|
||||
await resumed.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
List<WorkflowEvent> events = [];
|
||||
// Drain non-blocking on pending requests, matching the first-turn RunAsync path
|
||||
// (Run.RunToNextHaltAsync also uses blockOnPendingRequest: false): the workflow may halt awaiting an
|
||||
// external response, and blocking there would wait indefinitely.
|
||||
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
this.WarnOnNoProgress(sessionId);
|
||||
}
|
||||
|
||||
return this.Record(sessionId, events, resumed.LastCheckpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the events of a run-or-resume turn as they occur, applying the same restore-then-run semantics as
|
||||
/// <see cref="RunOrResumeAsync{TInput}(string, TInput, CancellationToken)"/>: the first turn runs the workflow
|
||||
/// forward from its start executor, and subsequent turns restore the session's latest checkpoint and run
|
||||
/// forward with <paramref name="input"/>. The session's head checkpoint is recorded when the stream ends,
|
||||
/// including when the consumer abandons enumeration early.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The head checkpoint is recorded from the run's last committed checkpoint when the stream ends — whether it
|
||||
/// completes normally or the consumer disposes it early — so an interrupted turn still advances the session
|
||||
/// cursor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TInput">The workflow input type.</typeparam>
|
||||
/// <param name="sessionId">The application-selected session id.</param>
|
||||
/// <param name="input">The input to run on this turn.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An asynchronous stream of the <see cref="WorkflowEvent"/>s emitted during this turn.</returns>
|
||||
public async IAsyncEnumerable<WorkflowEvent> RunOrResumeStreamingAsync<TInput>(string sessionId, TInput input, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
where TInput : notnull
|
||||
{
|
||||
_ = Throw.IfNullOrEmpty(sessionId);
|
||||
_ = Throw.IfNull(input);
|
||||
|
||||
Workflow workflow = await this.ResolveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!this._cursor.TryGetValue(sessionId, out CheckpointInfo? head))
|
||||
{
|
||||
head = await this._checkpointManager.GetLatestCheckpointAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The fresh streaming run enqueues the input itself; the streaming resume restores state and needs the
|
||||
// input delivered explicitly. Neither streaming entry point seeds a TurnToken, so drive chat-protocol
|
||||
// workflows with one on both paths.
|
||||
StreamingRun run = head is null
|
||||
? await this._executionEnvironment.RunStreamingAsync(workflow, input, sessionId, cancellationToken).ConfigureAwait(false)
|
||||
: await this._executionEnvironment.ResumeStreamingAsync(workflow, head, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using (run.ConfigureAwait(false))
|
||||
{
|
||||
if (head is not null)
|
||||
{
|
||||
await run.TrySendMessageAsync(input).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (descriptor.IsChatProtocol() && input is not TurnToken)
|
||||
{
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
int eventCount = 0;
|
||||
try
|
||||
{
|
||||
// Drain non-blocking on pending requests (see RunOrResumeCoreAsync) so a workflow that halts
|
||||
// awaiting an external response ends the stream instead of blocking indefinitely.
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
eventCount++;
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
if (eventCount == 0 && head is not null)
|
||||
{
|
||||
this.WarnOnNoProgress(sessionId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Record the head checkpoint even when the consumer abandons the stream (for example an SSE
|
||||
// client disconnect), so an interrupted turn still advances the session cursor to the last
|
||||
// committed checkpoint and a later turn resumes from there rather than re-running prior work.
|
||||
this.UpdateCursor(sessionId, run.LastCheckpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HostedWorkflowRunResult Record(string sessionId, List<WorkflowEvent> events, CheckpointInfo? checkpoint)
|
||||
{
|
||||
this.UpdateCursor(sessionId, checkpoint);
|
||||
return new HostedWorkflowRunResult(sessionId, events, checkpoint);
|
||||
}
|
||||
|
||||
private void UpdateCursor(string sessionId, CheckpointInfo? checkpoint)
|
||||
{
|
||||
if (checkpoint is not null)
|
||||
{
|
||||
this._cursor[sessionId] = checkpoint;
|
||||
}
|
||||
}
|
||||
|
||||
private void WarnOnNoProgress(string sessionId)
|
||||
// The resumed turn drove no work: the checkpoint may be stale or the input may not match the workflow's
|
||||
// expected type, so the session's state may not have progressed.
|
||||
=> this._logger.LogWorkflowResumeMadeNoProgress(sessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recorded head checkpoint for <paramref name="sessionId"/>, if any.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The application-selected session id.</param>
|
||||
/// <param name="checkpoint">When this method returns, the recorded head checkpoint, or <see langword="null"/>.</param>
|
||||
/// <returns><see langword="true"/> when a checkpoint is recorded for the session; otherwise <see langword="false"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Internal cursor-inspection helper used by tests; not part of the public surface.
|
||||
/// </remarks>
|
||||
internal bool TryGetCheckpoint(string sessionId, out CheckpointInfo? checkpoint)
|
||||
{
|
||||
_ = Throw.IfNullOrEmpty(sessionId);
|
||||
return this._cursor.TryGetValue(sessionId, out checkpoint);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
internal static partial class HostedWorkflowStateLogMessages
|
||||
{
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Resuming workflow session '{SessionId}' produced no events; the checkpoint may be stale or the input may not match the workflow's expected input type. Session state may not have progressed.")]
|
||||
public static partial void LogWorkflowResumeMadeNoProgress(this ILogger logger, string sessionId);
|
||||
}
|
||||
@@ -63,54 +63,47 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped session store IDs.
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped session store ID format {key}::{sessionStoreId} can be parsed correctly.
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped session store ID by prefixing the bare session store ID with the escaped isolation key.
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareSessionStoreId">The original session store ID.</param>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The scoped session store ID in the format {escapedKey}::{sessionStoreId}, or the bare session store ID
|
||||
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
|
||||
/// if no isolation key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedSessionStoreIdAsync(string bareSessionStoreId, CancellationToken cancellationToken)
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareSessionStoreId;
|
||||
return bareConversationId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}";
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedSessionStoreId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.DeleteSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, sessionStoreId)</c> only — it has no principal/owner dimension. When
|
||||
/// the session store id originates from the wire (for example, an AG-UI
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>RunAgentInput.ThreadId</c> or an A2A <c>contextId</c>), any caller who knows
|
||||
/// or guesses another caller's identifier can resume that other caller's persisted
|
||||
/// thread. Multi-user hosts must wrap this store in
|
||||
@@ -44,16 +44,16 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
private readonly ConcurrentDictionary<string, JsonElement> _threads = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(sessionStoreId, agent.Id);
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(sessionStoreId, agent.Id);
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null;
|
||||
|
||||
return sessionContent switch
|
||||
@@ -63,12 +63,5 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _);
|
||||
return default;
|
||||
}
|
||||
|
||||
private static string GetKey(string sessionStoreId, string agentId) => $"{agentId}:{sessionStoreId}";
|
||||
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
|
||||
}
|
||||
|
||||
@@ -12,20 +12,14 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
public sealed class NoopAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return default;
|
||||
return new ValueTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return agent.CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,6 @@ class _CodeValidator(ast.NodeVisitor):
|
||||
self._allowed_builtins = allowed_builtins if allowed_builtins is not None else ALLOWED_BUILTINS
|
||||
self._blocked_builtins = blocked_builtins if blocked_builtins is not None else BLOCKED_BUILTINS
|
||||
self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS
|
||||
self._os_aliases: set[str] = {"os"}
|
||||
|
||||
def validate(self, code: str) -> None:
|
||||
"""Validate code and raise CodeValidationError if it violates policy."""
|
||||
@@ -281,7 +280,6 @@ class _CodeValidator(ast.NodeVisitor):
|
||||
raise CodeValidationError(f"Syntax error in generated code: {exc}") from exc
|
||||
|
||||
self._errors = []
|
||||
self._os_aliases = {"os"}
|
||||
self.visit(tree)
|
||||
|
||||
if self._errors:
|
||||
@@ -305,10 +303,6 @@ class _CodeValidator(ast.NodeVisitor):
|
||||
self._errors.append(f"Import of '{alias_node.name}' is not allowed (blocked: {module_name})")
|
||||
elif module_name not in self._allowed_imports:
|
||||
self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)")
|
||||
if alias_node.name == "os":
|
||||
self._os_aliases.add(alias_node.asname or "os")
|
||||
elif alias_node.name.startswith("os.") and alias_node.asname is None:
|
||||
self._os_aliases.add("os")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
@@ -330,32 +324,6 @@ class _CodeValidator(ast.NodeVisitor):
|
||||
self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
"""Track re-bindings of the ``os`` module."""
|
||||
for target in node.targets:
|
||||
self._track_os_alias_targets(target, node.value)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
||||
"""Track annotated re-bindings of the ``os`` module."""
|
||||
if (
|
||||
isinstance(node.value, ast.Name)
|
||||
and node.value.id in self._os_aliases
|
||||
and isinstance(node.target, ast.Name)
|
||||
):
|
||||
self._os_aliases.add(node.target.id)
|
||||
self.generic_visit(node)
|
||||
|
||||
def _track_os_alias_targets(self, target: ast.AST, value: ast.AST) -> None:
|
||||
if isinstance(target, ast.Starred):
|
||||
target = target.value
|
||||
|
||||
if isinstance(target, ast.Name) and isinstance(value, ast.Name) and value.id in self._os_aliases:
|
||||
self._os_aliases.add(target.id)
|
||||
elif isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (ast.Tuple, ast.List)):
|
||||
for target_item, value_item in zip(target.elts, value.elts):
|
||||
self._track_os_alias_targets(target_item, value_item)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
"""Validate function calls.
|
||||
|
||||
@@ -389,7 +357,7 @@ class _CodeValidator(ast.NodeVisitor):
|
||||
# Enforce the `os` attribute allow-list. Anything outside `ALLOWED_OS_ATTRS`
|
||||
# (file I/O, process control, mutating helpers, etc.) is rejected so the
|
||||
# validator matches the documented `os.environ` / `os.path`-only contract.
|
||||
if isinstance(node.value, ast.Name) and node.value.id in self._os_aliases and node.attr not in self._allowed_os_attrs:
|
||||
if isinstance(node.value, ast.Name) and node.value.id == "os" and node.attr not in self._allowed_os_attrs:
|
||||
self._errors.append(f"Access to os.{node.attr} is not allowed")
|
||||
|
||||
# Block access to certain dangerous attributes
|
||||
|
||||
@@ -21,10 +21,9 @@ namespace Microsoft.Agents.AI.Tools.Shell;
|
||||
/// <para>
|
||||
/// The buffer counts UTF-8 bytes (matching the public <c>maxOutputBytes</c> contract
|
||||
/// and <see cref="ShellSession.TruncateHeadTail"/>). Append happens one rune at a time
|
||||
/// — once a complete rune no longer fits in the head, it and all later runes go to
|
||||
/// the tail as indivisible units. After the total exceeds the cap, the oldest tail
|
||||
/// runes are dropped. This guarantees the final string never contains a split rune
|
||||
/// (no orphan surrogates, no invalid UTF-8).
|
||||
/// — when the head fills, the next rune's UTF-8 bytes go to the tail as an indivisible
|
||||
/// unit, and the oldest rune is dropped from the tail. This guarantees the final
|
||||
/// string never contains a split rune (no orphan surrogates, no invalid UTF-8).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class HeadTailBuffer
|
||||
@@ -38,7 +37,6 @@ internal sealed class HeadTailBuffer
|
||||
private readonly Queue<byte[]> _tail = new();
|
||||
private int _tailBytes;
|
||||
private long _totalBytes;
|
||||
private bool _headSealed;
|
||||
|
||||
public HeadTailBuffer(int cap)
|
||||
{
|
||||
@@ -65,22 +63,19 @@ internal sealed class HeadTailBuffer
|
||||
var n = rune.EncodeToUtf8(scratch);
|
||||
this._totalBytes += n;
|
||||
|
||||
if (!this._headSealed && this._head.Count + n <= this._headCap)
|
||||
if (this._head.Count + n <= this._headCap)
|
||||
{
|
||||
for (var i = 0; i < n; i++) { this._head.Add(scratch[i]); }
|
||||
continue;
|
||||
}
|
||||
|
||||
// Once a complete rune cannot fit in the head, seal it and keep all later runes in the tail.
|
||||
this._headSealed = true;
|
||||
// Head is full — append to tail as a single rune-sized chunk.
|
||||
var bytes = scratch[..n].ToArray();
|
||||
this._tail.Enqueue(bytes);
|
||||
this._tailBytes += n;
|
||||
|
||||
// Evict whole runes from the front of the tail until we fit.
|
||||
while (this._totalBytes > this._cap &&
|
||||
this._tailBytes > this._tailCap &&
|
||||
this._tail.Count > 0)
|
||||
while (this._tailBytes > this._tailCap && this._tail.Count > 0)
|
||||
{
|
||||
var dropped = this._tail.Dequeue();
|
||||
this._tailBytes -= dropped.Length;
|
||||
|
||||
+8
-47
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -24,56 +22,19 @@ internal static class AgentProviderExtensions
|
||||
{
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
|
||||
|
||||
// Foundry managed workflows treat responses produced on the workflow conversation
|
||||
// as workflow output even when autoSend is explicitly false. Preserve that direct-run
|
||||
// contract here. Workflow.AsAIAgent separately removes matching streamed/completed
|
||||
// message duplicates at its hosting boundary.
|
||||
// Determine whether the target conversation is the workflow conversation
|
||||
// (used below to decide whether to mirror messages into the workflow conversation
|
||||
// when an agent runs against a different conversation). The caller's autoSend
|
||||
// value is honored as-is — when the workflow.yaml specifies autoSend: false the
|
||||
// raw agent output must not be streamed to the caller, even when the agent is
|
||||
// running on the workflow conversation.
|
||||
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
|
||||
autoSend |= isWorkflowConversation;
|
||||
|
||||
// Assign stable IDs to content-bearing chat updates before emitting and aggregating them.
|
||||
// Contentless updates may carry only metadata and must not become empty messages.
|
||||
// Process the agent response updates.
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
string? generatedMessageId = null;
|
||||
string? generatedMessageResponseId = null;
|
||||
ChatRole? generatedMessageRole = null;
|
||||
await foreach (AgentResponseUpdate update in agentUpdates.ConfigureAwait(false))
|
||||
{
|
||||
await AssignConversationIdAsync((update.RawRepresentation as ChatResponseUpdate)?.ConversationId).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(update.MessageId))
|
||||
{
|
||||
bool hasContent =
|
||||
update.Contents.Any(
|
||||
content => content is not TextContent textContent || !string.IsNullOrEmpty(textContent.Text));
|
||||
if (hasContent)
|
||||
{
|
||||
if (generatedMessageId is null
|
||||
|| (generatedMessageResponseId is not null
|
||||
&& update.ResponseId is not null
|
||||
&& !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal))
|
||||
|| (generatedMessageRole is not null
|
||||
&& update.Role is not null
|
||||
&& generatedMessageRole != update.Role))
|
||||
{
|
||||
generatedMessageId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
generatedMessageResponseId = update.ResponseId ?? generatedMessageResponseId;
|
||||
generatedMessageRole = update.Role ?? generatedMessageRole;
|
||||
update.MessageId = generatedMessageId;
|
||||
if (update.RawRepresentation is ChatResponseUpdate rawUpdate)
|
||||
{
|
||||
rawUpdate.MessageId = generatedMessageId;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
generatedMessageId = null;
|
||||
generatedMessageResponseId = null;
|
||||
generatedMessageRole = null;
|
||||
}
|
||||
await AssignConversationIdAsync(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false);
|
||||
|
||||
updates.Add(update);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user