Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5d282827 | |||
| 535690cd1d | |||
| e90b6de5a7 | |||
| d98ac29115 | |||
| 040e2705aa | |||
| 8c057507f4 | |||
| 0d5c0f8fa0 | |||
| 217912a2c0 | |||
| 0841116330 | |||
| cd6345e91e | |||
| 59b979213a | |||
| ad26cfe8c7 | |||
| 0c8bf5b6c0 | |||
| cb2914fa7e | |||
| 0796af0c26 | |||
| 711d6f24ae | |||
| bd17a64697 | |||
| 5147579992 | |||
| 2d34deeb82 | |||
| a2927c1c09 | |||
| c68c099347 | |||
| 61802723ff | |||
| ddb0622f9c | |||
| 12b23250f4 | |||
| bfc73a5b14 | |||
| 1f1da1bddb | |||
| 83ba938d1e | |||
| d97c901301 | |||
| d1d2610b28 | |||
| 848443ac68 | |||
| 1466d68cf1 | |||
| d08200d00e | |||
| fb38b1d10a | |||
| a70fe21298 | |||
| f6a3c43e9a | |||
| e6f7b3e9be | |||
| a1f3e536bc | |||
| c033adb1f4 | |||
| 09473fa7ed | |||
| a4f02aabf0 | |||
| afdf8af400 | |||
| 9e836f7b42 | |||
| 9cf5143321 | |||
| 85fde62a76 | |||
| 85eb53d412 | |||
| 2d7c8da6b0 | |||
| b6b16ddb75 | |||
| c218067646 | |||
| ac474100ce | |||
| a057cd505c | |||
| c66bb39ea2 | |||
| 7c6b1e975f | |||
| 0d2925037d | |||
| b5e635ed4d | |||
| 1036fa7438 | |||
| 62da382082 | |||
| 3604ba70f6 | |||
| 3ab2630243 | |||
| bc59c72170 | |||
| d5f2c77b35 | |||
| 6afae2f9b4 | |||
| cad81923e3 | |||
| 5ab8877ba5 | |||
| f4e49958f3 | |||
| 5282c158aa | |||
| dde7635760 | |||
| 85c00fc55b | |||
| f19a129b55 | |||
| a376577263 | |||
| b2549337ff | |||
| 05834b56e3 | |||
| 42ae534a07 | |||
| a17102f9f5 | |||
| e0b0b79d9e | |||
| a4f6c26990 | |||
| 1389f304f2 | |||
| 93719f4a34 | |||
| a486374fd8 | |||
| e78604103d |
@@ -0,0 +1,112 @@
|
||||
name: Get GitHub automation token
|
||||
description: Creates a GitHub App installation token with a temporary PAT fallback
|
||||
|
||||
inputs:
|
||||
mode:
|
||||
description: Authentication mode (app, app-with-fallback, or pat)
|
||||
required: false
|
||||
default: app-with-fallback
|
||||
azure-client-id:
|
||||
description: Client ID of the Azure workload identity
|
||||
required: false
|
||||
azure-tenant-id:
|
||||
description: Azure tenant ID
|
||||
required: false
|
||||
azure-subscription-id:
|
||||
description: Azure subscription containing the Key Vault
|
||||
required: false
|
||||
key-vault-name:
|
||||
description: Azure Key Vault name
|
||||
required: false
|
||||
key-name:
|
||||
description: Key Vault key used to sign the GitHub App JWT
|
||||
required: false
|
||||
github-app-client-id:
|
||||
description: GitHub App client ID
|
||||
required: false
|
||||
github-app-installation-id:
|
||||
description: GitHub App installation ID
|
||||
required: false
|
||||
repository:
|
||||
description: Repository to include in the installation token
|
||||
required: false
|
||||
fallback-token:
|
||||
description: PAT used temporarily when app authentication is unavailable
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: GitHub App installation token or fallback PAT
|
||||
value: ${{ steps.select-token.outputs.token }}
|
||||
source:
|
||||
description: Selected authentication source
|
||||
value: ${{ steps.select-token.outputs.source }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Validate authentication mode
|
||||
shell: bash
|
||||
env:
|
||||
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
|
||||
run: |
|
||||
if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then
|
||||
echo "::error::Unsupported GitHub authentication mode."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign in to Azure
|
||||
id: azure-login
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
|
||||
continue-on-error: true
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ inputs.azure-client-id }}
|
||||
tenant-id: ${{ inputs.azure-tenant-id }}
|
||||
subscription-id: ${{ inputs.azure-subscription-id }}
|
||||
|
||||
- name: Create GitHub App installation token
|
||||
id: app-token
|
||||
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
|
||||
KEY_VAULT_NAME: ${{ inputs.key-vault-name }}
|
||||
KEY_NAME: ${{ inputs.key-name }}
|
||||
GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }}
|
||||
GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }}
|
||||
TARGET_REPOSITORY: ${{ inputs.repository }}
|
||||
run: |
|
||||
token="$(node "$GITHUB_ACTION_PATH/create-token.js")"
|
||||
echo "::add-mask::$token"
|
||||
echo "token=$token" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Select authentication token
|
||||
id: select-token
|
||||
shell: bash
|
||||
env:
|
||||
AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }}
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
FALLBACK_TOKEN: ${{ inputs.fallback-token }}
|
||||
run: |
|
||||
if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then
|
||||
token="$APP_TOKEN"
|
||||
source="app"
|
||||
echo "::notice::GitHub authentication source: app"
|
||||
elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then
|
||||
token="$FALLBACK_TOKEN"
|
||||
source="pat-fallback"
|
||||
echo "::warning::GitHub authentication source: PAT fallback"
|
||||
elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then
|
||||
token="$FALLBACK_TOKEN"
|
||||
source="pat-forced"
|
||||
echo "::warning::GitHub authentication source: PAT (forced rollout mode)"
|
||||
else
|
||||
echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::$token"
|
||||
echo "token=$token" >> "$GITHUB_OUTPUT"
|
||||
echo "source=$source" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function base64Url(value) {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
function base64ToBase64Url(value) {
|
||||
return Buffer.from(value, 'base64').toString('base64url');
|
||||
}
|
||||
|
||||
function createJwtSigningInput(clientId, nowSeconds) {
|
||||
const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const payload = base64Url(JSON.stringify({
|
||||
iat: nowSeconds - 60,
|
||||
exp: nowSeconds + 540,
|
||||
iss: clientId,
|
||||
}));
|
||||
return `${header}.${payload}`;
|
||||
}
|
||||
|
||||
function signJwt(signingInput, config, execute = execFileSync) {
|
||||
const digest = crypto.createHash('sha256').update(signingInput).digest('base64');
|
||||
const signature = execute(
|
||||
'az',
|
||||
[
|
||||
'keyvault', 'key', 'sign',
|
||||
'--subscription', config.azureSubscriptionId,
|
||||
'--vault-name', config.keyVaultName,
|
||||
'--name', config.keyName,
|
||||
'--algorithm', 'RS256',
|
||||
'--digest', digest,
|
||||
'--query', 'signature',
|
||||
'--output', 'tsv',
|
||||
'--only-show-errors',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
|
||||
if (!signature) {
|
||||
throw new Error('Key Vault returned an empty signature.');
|
||||
}
|
||||
|
||||
return `${signingInput}.${base64ToBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
async function createInstallationToken(config, dependencies = {}) {
|
||||
const execute = dependencies.execute ?? execFileSync;
|
||||
const request = dependencies.fetch ?? fetch;
|
||||
const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000);
|
||||
const repositoryParts = config.targetRepository.split('/');
|
||||
|
||||
if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) {
|
||||
throw new Error('TARGET_REPOSITORY must use the owner/repository format.');
|
||||
}
|
||||
|
||||
const [, repository] = repositoryParts;
|
||||
const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds);
|
||||
const jwt = signJwt(signingInput, config, execute);
|
||||
|
||||
const response = await request(
|
||||
`https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repositories: [repository],
|
||||
permissions: {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
members: 'read',
|
||||
pull_requests: 'write',
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (typeof result.token !== 'string' || result.token.length === 0) {
|
||||
throw new Error('GitHub returned an empty installation token.');
|
||||
}
|
||||
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function readConfig(environment) {
|
||||
const config = {
|
||||
azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID,
|
||||
keyVaultName: environment.KEY_VAULT_NAME,
|
||||
keyName: environment.KEY_NAME,
|
||||
githubAppClientId: environment.GITHUB_APP_CLIENT_ID,
|
||||
githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID,
|
||||
targetRepository: environment.TARGET_REPOSITORY,
|
||||
};
|
||||
|
||||
if (Object.values(config).some((value) => !value)) {
|
||||
throw new Error('Required GitHub App authentication configuration is missing.');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const token = await createInstallationToken(readConfig(process.env));
|
||||
process.stdout.write(token);
|
||||
} catch {
|
||||
console.error('GitHub App token generation failed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
base64ToBase64Url,
|
||||
createInstallationToken,
|
||||
createJwtSigningInput,
|
||||
readConfig,
|
||||
signJwt,
|
||||
};
|
||||
@@ -1,25 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue author and check their team membership.
|
||||
* Resolve the issue or pull request author and check their team membership.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.github - Octokit REST client from actions/github-script
|
||||
* @param {object} opts.context - GitHub Actions context
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue number to resolve author for
|
||||
* @param {string|number} opts.issueNumber - Issue or pull request number to resolve author for
|
||||
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
|
||||
*/
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
|
||||
let author = context.payload.issue?.user?.login;
|
||||
let author =
|
||||
context.payload.issue?.user?.login ??
|
||||
context.payload.pull_request?.user?.login;
|
||||
|
||||
if (!author) {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(issueNumber),
|
||||
});
|
||||
author = issue.user?.login;
|
||||
const number = Number(issueNumber);
|
||||
if (context.payload.pull_request) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: number,
|
||||
});
|
||||
author = pr.user?.login;
|
||||
} else {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: number,
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# 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())
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
||||
const BRANCH_PATTERN = /^[a-zA-Z0-9_./-]+$/;
|
||||
|
||||
function assertValidSha(sha, description) {
|
||||
if (!SHA_PATTERN.test(sha)) {
|
||||
throw new Error(`GitHub returned an invalid ${description} SHA.`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasWritePermission(permissionData) {
|
||||
return permissionData.user?.permissions?.push === true
|
||||
|| ['admin', 'maintain', 'write'].includes(permissionData.permission);
|
||||
}
|
||||
|
||||
function latestDecisiveReviews(reviews) {
|
||||
const latestByReviewer = new Map();
|
||||
const sortedReviews = [...reviews].sort((left, right) => {
|
||||
const submittedComparison = (left.submitted_at || '').localeCompare(right.submitted_at || '');
|
||||
return submittedComparison || Number(left.id) - Number(right.id);
|
||||
});
|
||||
|
||||
for (const review of sortedReviews) {
|
||||
const state = review.state?.toUpperCase();
|
||||
const reviewer = review.user?.login?.toLowerCase();
|
||||
if (reviewer && DECISIVE_REVIEW_STATES.has(state)) {
|
||||
latestByReviewer.set(reviewer, review);
|
||||
}
|
||||
}
|
||||
|
||||
return latestByReviewer;
|
||||
}
|
||||
|
||||
async function resolvePullRequest({ github, context, core, prNumber, requiredApprovals }) {
|
||||
if (!/^[0-9]+$/.test(prNumber)) {
|
||||
throw new Error('Invalid PR number. Only numeric values are allowed.');
|
||||
}
|
||||
|
||||
const pullNumber = Number(prNumber);
|
||||
const { data: pullRequest } = await github.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
if (pullRequest.state !== 'open') {
|
||||
throw new Error(`PR #${pullNumber} is not open (state: ${pullRequest.state}).`);
|
||||
}
|
||||
|
||||
const headSha = pullRequest.head.sha;
|
||||
const baseSha = pullRequest.base.sha;
|
||||
assertValidSha(headSha, 'PR head');
|
||||
assertValidSha(baseSha, 'PR base');
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
...context.repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const latestReviews = latestDecisiveReviews(reviews);
|
||||
const author = pullRequest.user?.login?.toLowerCase();
|
||||
const approvalCandidates = [...latestReviews.entries()]
|
||||
.filter(([, review]) => review.state.toUpperCase() === 'APPROVED')
|
||||
.filter(([, review]) => review.commit_id === headSha)
|
||||
.filter(([reviewer]) => reviewer !== author);
|
||||
|
||||
const approvedMaintainers = [];
|
||||
for (const [reviewer] of approvalCandidates) {
|
||||
const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
...context.repo,
|
||||
username: reviewer,
|
||||
});
|
||||
if (hasWritePermission(permissionData)) {
|
||||
approvedMaintainers.push(reviewer);
|
||||
} else {
|
||||
core.info(`Ignoring approval from ${reviewer}: reviewer does not have write permission.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (approvedMaintainers.length < requiredApprovals) {
|
||||
throw new Error(
|
||||
`PR #${pullNumber} head ${headSha} requires ${requiredApprovals} approvals from unique `
|
||||
+ `write-capable maintainers; found ${approvedMaintainers.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
core.info(
|
||||
`PR #${pullNumber} head ${headSha} approved by: ${approvedMaintainers.join(', ')}.`,
|
||||
);
|
||||
return {
|
||||
baseRef: baseSha,
|
||||
checkoutRef: headSha,
|
||||
description: `PR #${pullNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBranch({ github, context, core, branch }) {
|
||||
if (!BRANCH_PATTERN.test(branch)) {
|
||||
throw new Error(
|
||||
'Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes '
|
||||
+ 'are allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const [{ data: repository }, { data: targetBranch }] = await Promise.all([
|
||||
github.rest.repos.get(context.repo),
|
||||
github.rest.repos.getBranch({ ...context.repo, branch }),
|
||||
]);
|
||||
const { data: baseBranch } = await github.rest.repos.getBranch({
|
||||
...context.repo,
|
||||
branch: repository.default_branch,
|
||||
});
|
||||
|
||||
const checkoutRef = targetBranch.commit.sha;
|
||||
const baseRef = baseBranch.commit.sha;
|
||||
assertValidSha(checkoutRef, 'branch head');
|
||||
assertValidSha(baseRef, 'default branch');
|
||||
core.info(`Branch ${branch} resolved to immutable commit ${checkoutRef}.`);
|
||||
|
||||
return {
|
||||
baseRef,
|
||||
checkoutRef,
|
||||
description: `branch ${branch}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manually requested integration-test target to an immutable commit.
|
||||
*
|
||||
* Pull requests must have fresh approvals from two unique write-capable
|
||||
* maintainers for the exact head commit. Branches are limited to branches in
|
||||
* the base repository and are pinned to their current commit.
|
||||
*/
|
||||
async function resolveIntegrationTestTarget({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber = '',
|
||||
branch = '',
|
||||
requiredApprovals = 2,
|
||||
}) {
|
||||
const normalizedPrNumber = prNumber.trim();
|
||||
const normalizedBranch = branch.trim();
|
||||
|
||||
if (normalizedPrNumber && normalizedBranch) {
|
||||
throw new Error('Please provide either a PR number or a branch name, not both.');
|
||||
}
|
||||
if (!normalizedPrNumber && !normalizedBranch) {
|
||||
throw new Error('Please provide either a PR number or a branch name.');
|
||||
}
|
||||
|
||||
if (normalizedPrNumber) {
|
||||
return resolvePullRequest({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber: normalizedPrNumber,
|
||||
requiredApprovals,
|
||||
});
|
||||
}
|
||||
return resolveBranch({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
branch: normalizedBranch,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = resolveIntegrationTestTarget;
|
||||
@@ -16,7 +16,12 @@ const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
function createMocks({
|
||||
payloadIssue = undefined,
|
||||
payloadPullRequest = undefined,
|
||||
apiUser = 'api-user',
|
||||
teamState = 'active',
|
||||
} = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
@@ -24,8 +29,16 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const payload = {};
|
||||
if (payloadIssue !== undefined) {
|
||||
payload.issue = payloadIssue;
|
||||
}
|
||||
if (payloadPullRequest !== undefined) {
|
||||
payload.pull_request = payloadPullRequest;
|
||||
}
|
||||
|
||||
const context = {
|
||||
payload: { issue: payloadIssue },
|
||||
payload,
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
@@ -36,6 +49,11 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
@@ -64,6 +82,37 @@ describe('author resolution', () => {
|
||||
assert.equal(result.author, 'payload-user');
|
||||
});
|
||||
|
||||
it('resolves author from pull_request event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadPullRequest: { user: { login: 'pr-author' } },
|
||||
});
|
||||
let issuesGetCalled = false;
|
||||
github.rest.issues.get = async () => {
|
||||
issuesGetCalled = true;
|
||||
return { data: { user: { login: 'api-user' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'pr-author');
|
||||
assert.equal(issuesGetCalled, false);
|
||||
});
|
||||
|
||||
it('resolves author via pulls API when pull_request payload user is null', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadPullRequest: { user: null },
|
||||
apiUser: 'fetched-pr-author',
|
||||
});
|
||||
let pullsGetCalled = false;
|
||||
github.rest.pulls.get = async () => {
|
||||
pullsGetCalled = true;
|
||||
return { data: { user: { login: 'fetched-pr-author' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'fetched-pr-author');
|
||||
assert.equal(pullsGetCalled, true);
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue is absent', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: 'api-user' });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
base64ToBase64Url,
|
||||
createInstallationToken,
|
||||
createJwtSigningInput,
|
||||
readConfig,
|
||||
} = require('../actions/github-app-token/create-token.js');
|
||||
|
||||
const CONFIG = {
|
||||
azureSubscriptionId: 'subscription-id',
|
||||
keyVaultName: 'vault-name',
|
||||
keyName: 'key-name',
|
||||
githubAppClientId: 'client-id',
|
||||
githubAppInstallationId: '12345',
|
||||
targetRepository: 'microsoft/agent-framework',
|
||||
};
|
||||
|
||||
describe('GitHub App token creation', () => {
|
||||
it('creates a short-lived GitHub App JWT', () => {
|
||||
const signingInput = createJwtSigningInput('client-id', 1_000);
|
||||
const [encodedHeader, encodedPayload] = signingInput.split('.');
|
||||
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString());
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString());
|
||||
|
||||
assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' });
|
||||
assert.deepEqual(payload, { iat: 940, exp: 1_540, iss: 'client-id' });
|
||||
});
|
||||
|
||||
it('converts Key Vault signatures to unpadded base64url', () => {
|
||||
assert.equal(base64ToBase64Url('+/8='), '-_8');
|
||||
});
|
||||
|
||||
it('requests a repository-scoped installation token', async () => {
|
||||
let request;
|
||||
const token = await createInstallationToken(CONFIG, {
|
||||
nowSeconds: 1_000,
|
||||
execute: (command, args) => {
|
||||
assert.equal(command, 'az');
|
||||
assert.ok(args.includes('RS256'));
|
||||
return '+/8=\n';
|
||||
},
|
||||
fetch: async (url, options) => {
|
||||
request = { url, options };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ token: 'installation-token' }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(token, 'installation-token');
|
||||
assert.equal(request.url, 'https://api.github.com/app/installations/12345/access_tokens');
|
||||
assert.match(request.options.headers.Authorization, /^Bearer [^.]+\.[^.]+\.-_8$/);
|
||||
assert.deepEqual(JSON.parse(request.options.body), {
|
||||
repositories: ['agent-framework'],
|
||||
permissions: {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
members: 'read',
|
||||
pull_requests: 'write',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete configuration', () => {
|
||||
assert.throws(
|
||||
() => readConfig({}),
|
||||
/Required GitHub App authentication configuration is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects repository values with extra path segments before signing', async () => {
|
||||
let signed = false;
|
||||
|
||||
await assert.rejects(
|
||||
createInstallationToken(
|
||||
{ ...CONFIG, targetRepository: 'microsoft/agent-framework/extra' },
|
||||
{
|
||||
execute: () => {
|
||||
signed = true;
|
||||
return '+/8=\n';
|
||||
},
|
||||
},
|
||||
),
|
||||
/TARGET_REPOSITORY must use the owner\/repository format/,
|
||||
);
|
||||
assert.equal(signed, false);
|
||||
});
|
||||
|
||||
it('rejects an empty Key Vault signature', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '\n',
|
||||
}),
|
||||
/Key Vault returned an empty signature/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a failed GitHub token request', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '+/8=\n',
|
||||
fetch: async () => ({ ok: false, status: 403 }),
|
||||
}),
|
||||
/GitHub installation token request failed with HTTP 403/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty GitHub installation token', async () => {
|
||||
await assert.rejects(
|
||||
createInstallationToken(CONFIG, {
|
||||
execute: () => '+/8=\n',
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: '' }),
|
||||
}),
|
||||
}),
|
||||
/GitHub returned an empty installation token/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
# 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()
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for resolve_integration_test_target.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_resolve_integration_test_target.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const resolveIntegrationTestTarget = require('../scripts/resolve_integration_test_target.js');
|
||||
|
||||
const HEAD_SHA = 'a'.repeat(40);
|
||||
const BASE_SHA = 'b'.repeat(40);
|
||||
|
||||
function review({
|
||||
id,
|
||||
login,
|
||||
state = 'APPROVED',
|
||||
commitId = HEAD_SHA,
|
||||
submittedAt = `2026-07-13T00:00:${String(id).padStart(2, '0')}Z`,
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
commit_id: commitId,
|
||||
submitted_at: submittedAt,
|
||||
user: { login },
|
||||
};
|
||||
}
|
||||
|
||||
function createMocks({
|
||||
pullState = 'open',
|
||||
pullAuthor = 'contributor',
|
||||
reviews = [],
|
||||
permissions = {},
|
||||
} = {}) {
|
||||
const core = {
|
||||
infoMessages: [],
|
||||
info(message) {
|
||||
this.infoMessages.push(message);
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
repo: { owner: 'microsoft', repo: 'agent-framework' },
|
||||
};
|
||||
const github = {
|
||||
paginate: async () => reviews,
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
state: pullState,
|
||||
user: { login: pullAuthor },
|
||||
head: { sha: HEAD_SHA },
|
||||
base: { sha: BASE_SHA },
|
||||
},
|
||||
}),
|
||||
listReviews: async () => {},
|
||||
},
|
||||
repos: {
|
||||
get: async () => ({ data: { default_branch: 'main' } }),
|
||||
getBranch: async ({ branch }) => ({
|
||||
data: { commit: { sha: branch === 'main' ? BASE_SHA : HEAD_SHA } },
|
||||
}),
|
||||
getCollaboratorPermissionLevel: async ({ username }) => ({
|
||||
data: permissions[username] || {
|
||||
permission: 'read',
|
||||
user: { permissions: { push: false } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { core, context, github };
|
||||
}
|
||||
|
||||
const WRITE_PERMISSION = {
|
||||
permission: 'write',
|
||||
user: { permissions: { push: true } },
|
||||
};
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects missing and conflicting targets', async () => {
|
||||
const mocks = createMocks();
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget(mocks),
|
||||
/provide either a PR number or a branch name/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1', branch: 'feature' }),
|
||||
/not both/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid PR numbers and branch names', async () => {
|
||||
const mocks = createMocks();
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1;echo' }),
|
||||
/Invalid PR number/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, branch: 'feature branch' }),
|
||||
/Invalid branch name/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pull request resolution', () => {
|
||||
it('pins an open PR with two fresh write-capable approvals', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'maintainer-one' }),
|
||||
review({ id: 2, login: 'maintainer-two' }),
|
||||
],
|
||||
permissions: {
|
||||
'maintainer-one': WRITE_PERMISSION,
|
||||
'maintainer-two': WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
baseRef: BASE_SHA,
|
||||
checkoutRef: HEAD_SHA,
|
||||
description: 'PR #123',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects closed PRs', async () => {
|
||||
const mocks = createMocks({ pullState: 'closed' });
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/is not open/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale, self, and read-only approvals', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'stale', commitId: 'c'.repeat(40) }),
|
||||
review({ id: 2, login: 'contributor' }),
|
||||
review({ id: 3, login: 'reader' }),
|
||||
review({ id: 4, login: 'maintainer' }),
|
||||
],
|
||||
permissions: {
|
||||
contributor: WRITE_PERMISSION,
|
||||
reader: { permission: 'read', user: { permissions: { push: false } } },
|
||||
maintainer: WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/found 1/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses each reviewer latest decisive review and ignores later comments', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'changes-requested' }),
|
||||
review({ id: 2, login: 'changes-requested', state: 'CHANGES_REQUESTED' }),
|
||||
review({ id: 3, login: 'maintainer-one' }),
|
||||
review({ id: 4, login: 'maintainer-one', state: 'COMMENTED' }),
|
||||
review({ id: 5, login: 'maintainer-two' }),
|
||||
],
|
||||
permissions: {
|
||||
'changes-requested': WRITE_PERMISSION,
|
||||
'maintainer-one': WRITE_PERMISSION,
|
||||
'maintainer-two': WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
|
||||
assert.equal(result.checkoutRef, HEAD_SHA);
|
||||
});
|
||||
|
||||
it('does not count a dismissed approval', async () => {
|
||||
const mocks = createMocks({
|
||||
reviews: [
|
||||
review({ id: 1, login: 'dismissed', state: 'DISMISSED' }),
|
||||
review({ id: 2, login: 'maintainer' }),
|
||||
],
|
||||
permissions: {
|
||||
dismissed: WRITE_PERMISSION,
|
||||
maintainer: WRITE_PERMISSION,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
|
||||
/found 1/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch resolution', () => {
|
||||
it('pins base-repository branches and their comparison base to SHAs', async () => {
|
||||
const mocks = createMocks();
|
||||
const result = await resolveIntegrationTestTarget({ ...mocks, branch: 'feature/test' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
baseRef: BASE_SHA,
|
||||
checkoutRef: HEAD_SHA,
|
||||
description: 'branch feature/test',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -31,6 +32,7 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
pr_number: ${{ steps.pr.outputs.pr_number }}
|
||||
@@ -64,6 +66,31 @@ jobs:
|
||||
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts/check_team_membership.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
@@ -71,31 +98,16 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
let author = context.payload.pull_request?.user?.login;
|
||||
if (!author) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(process.env.PR_NUMBER),
|
||||
});
|
||||
author = pr.user.login;
|
||||
}
|
||||
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
console.log(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
isTeamMember = false;
|
||||
}
|
||||
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.PR_NUMBER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; proceeding with review.`);
|
||||
@@ -107,6 +119,10 @@ 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,6 +163,7 @@ jobs:
|
||||
# Change to project directory to ensure local nuget.config is used
|
||||
pushd consoleapp
|
||||
dotnet add packcheck.csproj package Microsoft.Agents.AI --prerelease
|
||||
dotnet add packcheck.csproj package Microsoft.Agents.AI.LocalCodeAct --prerelease
|
||||
dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj
|
||||
|
||||
# Clean up
|
||||
|
||||
@@ -9,16 +9,31 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
description: "Immutable commit SHA to check out"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TENANT_ID:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZUREAI__ENDPOINT:
|
||||
required: true
|
||||
COPILOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
dotnet-integration-tests:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 a ref so they check out and test the correct code.
|
||||
# passing an immutable commit SHA so they check out and test the approved code.
|
||||
# Changed paths are detected here so only the relevant test suites run.
|
||||
#
|
||||
|
||||
@@ -26,7 +26,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
|
||||
@@ -38,67 +37,50 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
base-ref: ${{ steps.resolve.outputs.base-ref }}
|
||||
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
|
||||
python-changes: ${{ steps.detect-changes.outputs.python }}
|
||||
steps:
|
||||
- name: Resolve checkout ref
|
||||
- name: Check out trusted workflow helpers
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Resolve and authorize checkout ref
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const resolveIntegrationTestTarget = require(
|
||||
'./.github/scripts/resolve_integration_test_target.js'
|
||||
);
|
||||
const target = await resolveIntegrationTestTarget({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber: process.env.PR_NUMBER,
|
||||
branch: process.env.BRANCH,
|
||||
});
|
||||
core.setOutput('checkout-ref', target.checkoutRef);
|
||||
core.setOutput('base-ref', target.baseRef);
|
||||
core.info(`Running integration tests for ${target.description} at ${target.checkoutRef}.`);
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name, not both."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
|
||||
echo "::error::Please provide either a PR number or a branch name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
|
||||
echo "::error::Invalid PR number. Only numeric values are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
|
||||
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
|
||||
|
||||
if [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for PR #$PR_NUMBER"
|
||||
else
|
||||
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
|
||||
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "Running integration tests for branch $BRANCH"
|
||||
fi
|
||||
|
||||
- name: Detect changed paths
|
||||
id: detect-changes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr-number }}
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
BASE_REF: ${{ steps.resolve.outputs.base-ref }}
|
||||
CHECKOUT_REF: ${{ steps.resolve.outputs.checkout-ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
|
||||
else
|
||||
# For branches, compare against main using the GitHub API
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
|
||||
fi
|
||||
CHANGED_FILES=$(gh api "repos/$REPO/compare/$BASE_REF...$CHECKOUT_REF" \
|
||||
--jq '.files[].filename')
|
||||
|
||||
DOTNET_CHANGES=false
|
||||
PYTHON_CHANGES=false
|
||||
@@ -113,22 +95,41 @@ jobs:
|
||||
|
||||
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected changes — dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
echo "Detected changes; dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
|
||||
|
||||
dotnet-integration-tests:
|
||||
name: .NET Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/dotnet-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZUREAI__ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
python-integration-tests:
|
||||
name: Python Integration Tests
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.python-changes == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/python-integration-tests.yml
|
||||
with:
|
||||
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -33,6 +33,7 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch'
|
||||
@@ -68,10 +69,27 @@ jobs:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check issue author team membership
|
||||
if: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
id: check
|
||||
@@ -80,7 +98,7 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -106,6 +124,10 @@ jobs:
|
||||
|| needs.team_check.outputs.is_team_member == 'false'
|
||||
}}
|
||||
environment: integration
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
|
||||
@@ -10,12 +10,39 @@ jobs:
|
||||
name: "Issue: add labels"
|
||||
if: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout GitHub automation
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts/check_team_membership.js
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
// Get the issue body and title
|
||||
const body = context.payload.issue.body
|
||||
@@ -24,21 +51,14 @@ jobs:
|
||||
// Define the labels array
|
||||
let labels = []
|
||||
|
||||
// Check if the issue author is in the agentframework-developers team
|
||||
let isTeamMember = false
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: context.payload.issue.user.login
|
||||
})
|
||||
console.log("Team Membership Data:", teamMembership);
|
||||
isTeamMember = teamMembership.data.state === 'active'
|
||||
} catch (error) {
|
||||
// User is not in the team or team doesn't exist
|
||||
console.error("Error fetching team membership:", error);
|
||||
isTeamMember = false
|
||||
}
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js')
|
||||
const { isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: context.issue.number,
|
||||
})
|
||||
|
||||
// Only add triage label if the author is not in the team
|
||||
if (!isTeamMember) {
|
||||
|
||||
@@ -13,27 +13,47 @@ on:
|
||||
jobs:
|
||||
add_label:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: "${{ secrets.GH_ACTIONS_PR_WRITE }}"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
|
||||
with:
|
||||
repo-token: ${{ steps.github-auth.outputs.token }}
|
||||
|
||||
- name: "PR: add breaking change label from title"
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const { syncBreakingChangeLabelFromTitle } = require('./.github/scripts/title_prefix.js');
|
||||
await syncBreakingChangeLabelFromTitle({ github, context, core });
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -21,16 +22,35 @@ env:
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
@@ -38,7 +58,7 @@ jobs:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
@@ -57,20 +77,39 @@ jobs:
|
||||
|
||||
limit_open_prs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
.github/actions/github-app-token
|
||||
.github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Enforce open PR limit
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
const { enforcePrLimit } = require('./.github/scripts/pr_limit_moderation.js');
|
||||
await enforcePrLimit({
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
#!/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,13 +13,27 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout-ref:
|
||||
description: "Git ref to checkout (e.g., refs/pull/123/head)"
|
||||
description: "Immutable commit SHA to check out"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
ANTHROPIC_API_KEY:
|
||||
required: true
|
||||
AZURE_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TENANT_ID:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
COPILOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
FOUNDRY_MODELS_API_KEY:
|
||||
required: false
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -99,6 +113,9 @@ jobs:
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
name: Python Integration Tests - Azure OpenAI
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -224,6 +241,7 @@ jobs:
|
||||
packages/hyperlight/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
packages/hosting-mcp/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
@@ -260,6 +278,9 @@ jobs:
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
name: Python Integration Tests - Functions
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -324,6 +345,9 @@ jobs:
|
||||
# Foundry integration tests
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
@@ -378,6 +402,9 @@ jobs:
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -71,6 +71,7 @@ jobs:
|
||||
- 'python/packages/ollama/**'
|
||||
- 'python/packages/core/agent_framework/_mcp.py'
|
||||
- 'python/packages/core/tests/core/test_mcp.py'
|
||||
- 'python/packages/hosting-mcp/**'
|
||||
- 'python/scripts/local_mcp_streamable_http_server.py'
|
||||
- '.github/actions/setup-local-mcp-server/**'
|
||||
- '.github/workflows/python-merge-tests.yml'
|
||||
@@ -345,6 +346,7 @@ jobs:
|
||||
packages/hyperlight/tests
|
||||
packages/ollama/tests
|
||||
packages/core/tests/core/test_mcp.py
|
||||
packages/hosting-mcp/tests
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
|
||||
@@ -6,6 +6,9 @@ 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
|
||||
@@ -37,10 +40,10 @@ jobs:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run all tests with coverage report
|
||||
- name: Run aggregate 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/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
|
||||
run: python ${{ github.workspace }}/.github/scripts/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' || '' }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
@@ -26,13 +26,30 @@ jobs:
|
||||
ping_stale:
|
||||
name: "Ping stale issues and PRs"
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Get GitHub automation token
|
||||
id: github-auth
|
||||
uses: ./.github/actions/github-app-token
|
||||
with:
|
||||
mode: ${{ vars.GH_APP_AUTH_MODE }}
|
||||
azure-client-id: ${{ secrets.GH_APP_AZURE_CLIENT_ID }}
|
||||
azure-tenant-id: ${{ secrets.GH_APP_AZURE_TENANT_ID }}
|
||||
azure-subscription-id: ${{ secrets.GH_APP_AZURE_SUBSCRIPTION_ID }}
|
||||
key-vault-name: ${{ secrets.GH_APP_KEY_VAULT_NAME }}
|
||||
key-name: ${{ secrets.GH_APP_KEY_NAME }}
|
||||
github-app-client-id: ${{ secrets.GH_APP_CLIENT_ID }}
|
||||
github-app-installation-id: ${{ secrets.GH_APP_INSTALLATION_ID }}
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
@@ -43,7 +60,7 @@ jobs:
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
|
||||
@@ -204,7 +204,8 @@ safe to use:
|
||||
transient execution.
|
||||
|
||||
A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent
|
||||
target and creates the session on first use:
|
||||
target and creates the session on first use. Reads return independent working copies so running from one continuation
|
||||
point does not mutate the stored snapshot or another simultaneous branch:
|
||||
|
||||
For agent targets:
|
||||
|
||||
@@ -227,6 +228,11 @@ await state.set_session(response_id, session)
|
||||
`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call
|
||||
belongs after the run, not before it.
|
||||
|
||||
Response ids are immutable continuation points, so simultaneous callers can branch from one `previous_response_id` and
|
||||
store their completed sessions under different new response ids. A stable `conversation_id` is a mutable head: the app
|
||||
must explicitly update it after the run and provide single-writer coordination. The hosting state helper does not lock
|
||||
an entire run or resolve concurrent updates to that stable key.
|
||||
|
||||
The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any
|
||||
externally supplied key before using it.
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-07-08
|
||||
deciders: rogerbarreto
|
||||
consulted: eavanvalkenburg
|
||||
informed: []
|
||||
---
|
||||
|
||||
# .NET hosting: OpenAI Responses protocol helpers for app-owned routing
|
||||
|
||||
Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET.
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel
|
||||
framework toward **protocol conversion helpers plus optional execution state**: Agent Framework owns
|
||||
protocol-native <-> run conversion, while the application owns HTTP routing, authentication,
|
||||
middleware, storage, and native SDK calls.
|
||||
|
||||
.NET already ships `Microsoft.Agents.AI.Hosting.OpenAI`, a route-owning server that **exposes an
|
||||
`AIAgent` (or workflow) as the OpenAI Responses API** (`MapOpenAIResponses` + `IResponsesService`). It
|
||||
owns the routes, an in-memory response/conversation store, streaming, and lifecycle. The question is
|
||||
what, if anything, .NET must add to satisfy the ADR-0027 boundary.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Do not reinvent conversion logic that already exists and is battle-tested in `Hosting.OpenAI`.
|
||||
- Give applications a way to own their own route/auth/middleware/storage while reusing Agent Framework
|
||||
conversion (the ADR-0027 boundary).
|
||||
- Keep the released public surface small.
|
||||
- Stay consistent with the existing .NET hosting stack, which deliberately does **not** use the OpenAI
|
||||
SDK Responses types server-side (it hand-rolled its own wire model).
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Self-contained new package that reimplements conversion using the OpenAI SDK Responses types
|
||||
(mirrors the Python `agent-framework-hosting-responses` lineage).
|
||||
2. New package that reuses `Hosting.OpenAI`'s internal converters (via `InternalsVisibleTo` or by
|
||||
moving the conversion core out).
|
||||
3. Thin public helper facade **inside** `Hosting.OpenAI` over the existing internal converters, plus
|
||||
protocol-neutral execution-state holders in `Microsoft.Agents.AI.Hosting`.
|
||||
|
||||
### First-principles gap analysis
|
||||
|
||||
A capability comparison of the ADR-0027 / PR #6891 helper surface against the existing .NET stack:
|
||||
|
||||
| Python helper capability | .NET today | Status |
|
||||
| --- | --- | --- |
|
||||
| `responses_to_run` | `ResponseInput.GetInputMessages` + `InputMessage.ToChatMessage` + `OpenAIResponsesMapOptions.RunOptionsFactory` | exists, internal |
|
||||
| `responses_from_run` | `AgentResponseExtensions.ToResponse` | exists, internal |
|
||||
| `responses_from_streaming_run` | `AgentResponseUpdateExtensions.ToStreamingResponseAsync` + `SseJsonResult` (also renders workflow events) | exists, internal, richer |
|
||||
| `responses_session_id` | continuity resolved inside `InMemoryResponsesService` | exists, internal, not standalone |
|
||||
| `create_response_id` | `IdGenerator` | exists, internal |
|
||||
| `AgentState` (target + store, get-or-create, callable/awaitable target) | `AgentSessionStore` (get-or-create + save + serialize + isolation) + DI container (target lifetime + async setup) | create-on-miss lives in the store; per-run instance and deferred/async target come from DI, so no separate holder is needed |
|
||||
| `SessionStore` (get/set/delete) | `AgentSessionStore` + `InMemoryAgentSessionStore` | richer; `Delete` added |
|
||||
| `WorkflowState` + checkpoint resume | `WorkflowCatalog`/`HostedWorkflowBuilder`; workflow events already render over Responses; `CheckpointManager` is session-keyed | partial; no per-session checkpoint cursor |
|
||||
| App owns routing/auth/middleware/storage | `MapOpenAIResponses`/`IResponsesService` own routing + storage | **the one real gap** |
|
||||
|
||||
.NET already covers ~90% of the capability, and more richly (its streaming renderer even emits workflow
|
||||
events; its session store serializes and supports per-principal isolation, neither of which Python's
|
||||
in-memory `SessionStore` does). The single genuine gap is the **ownership model**: every conversion
|
||||
primitive is bundled behind the route-owning server, so an application cannot own its own route and
|
||||
call just the conversion.
|
||||
|
||||
Note on lineage: Python's Responses offering was introduced *as a channel* (PR #6580) and always used
|
||||
the `openai` SDK Responses types. .NET's `Hosting.OpenAI` predates and is independent of channels and
|
||||
hand-rolled its own server-side wire DTOs (the SDK's Responses types are client-shaped and awkward
|
||||
server-side). So Option 1 would both reinvent a working asset and contradict the .NET codebase's own
|
||||
precedent.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **3. Thin public helper facade inside `Hosting.OpenAI` plus neutral state holders**,
|
||||
because the only real gap is the ownership model, so the work is to *un-bundle* the existing
|
||||
converters, not to rebuild them or add a package.
|
||||
|
||||
### Public surface
|
||||
|
||||
`Microsoft.Agents.AI.Hosting.OpenAI` gains a single public static facade, `OpenAIResponses`, whose
|
||||
boundary is `System.Text.Json` (`JsonElement`/streamed events), matching Python's dict boundary and
|
||||
keeping the hand-rolled wire DTOs internal:
|
||||
|
||||
- `OpenAIResponses.ToAgentRunRequest(JsonElement body)` -> messages + `AgentRunOptions?`.
|
||||
- `OpenAIResponses.WriteResponse(AgentRunResponse response, string responseId, string? sessionId = null)`
|
||||
-> a Responses-shaped `JsonElement`.
|
||||
- `OpenAIResponses.WriteResponseStreamAsync(IAsyncEnumerable<AgentRunResponseUpdate> updates, string responseId, ...)`
|
||||
-> Responses SSE `data:` frames.
|
||||
- `OpenAIResponses.GetSessionId(JsonElement body)` -> `previous_response_id` or `conversation` id, or
|
||||
`null`. Kept **separate** from `ToAgentRunRequest` so the trust boundary is visible: choosing to use
|
||||
a request-derived key is an explicit application decision.
|
||||
- `OpenAIResponses.CreateResponseId()` -> a `resp_*` id.
|
||||
|
||||
All helpers are side-effect-free and delegate to the existing internal converters. `MapOpenAIResponses`
|
||||
public behavior is unchanged; it and the facade share one internal conversion core (an internal
|
||||
`ToResponse` overload with an optional originating request is added so the facade can render without a
|
||||
request object).
|
||||
|
||||
### Optional execution state (neutral package)
|
||||
|
||||
`Microsoft.Agents.AI.Hosting` gains:
|
||||
|
||||
- `AgentSessionStore.DeleteSessionAsync(...)` (+ `InMemoryAgentSessionStore` implementation and
|
||||
isolation-decorator passthrough): the one missing store operation.
|
||||
- No agent-side holder. Applications use `AgentSessionStore` directly: `GetSessionAsync(agent, id)`
|
||||
already creates on miss and returns an independent session instance per call (so concurrent calls fork
|
||||
the same stored state rather than sharing an instance), `SaveSessionAsync(agent, id, session)` persists
|
||||
post-run (including under a newly minted id), and `DeleteSessionAsync(agent, id)` removes it. An earlier
|
||||
draft added a `HostedAgentState` holder, but once create-on-miss lives in the store and the store does no
|
||||
cross-call locking, the holder would only bind the `agent` argument, which is not enough to justify a
|
||||
public type. Any coordination for concurrent runs against the same id is the application's concern.
|
||||
(Unlike Python, whose `SessionStore` is get/set-only and whose `AgentState` therefore owns
|
||||
create-on-miss, .NET's store already owns it.)
|
||||
|
||||
Python's `AgentState` carries two further responsibilities beyond create-on-miss: it accepts a callable
|
||||
or awaitable target so the host can (1) obtain a fresh agent instance per run and (2) defer expensive or
|
||||
asynchronous agent setup while keeping server construction synchronous. In .NET these two concerns are
|
||||
owned by the dependency-injection container, not by a hosting type. Per-run lifetime is expressed by the
|
||||
registration lifetime (`AddScoped`/`AddTransient` yields a fresh `AIAgent` per request or scope, resolved
|
||||
by the framework), and deferred or asynchronous construction is expressed by an async factory registration
|
||||
(for example an `async` factory delegate, `ActivatorUtilities`, or resolving the agent inside the request
|
||||
after any async warm-up), so the route handler resolves an already-built agent from the container. An
|
||||
`AIAgent` is also safe to invoke concurrently (per-turn state lives in `AgentSession`, not the agent), so
|
||||
the "fresh instance per run" motivation does not apply to it the way it does to a workflow. This is the
|
||||
deliberate asymmetry with `HostedWorkflowState` below: a `Workflow` instance is a stateful run engine that
|
||||
cannot be driven by two runners at once, so the factory/`cacheWorkflow` affordance is load-bearing there
|
||||
for correctness, whereas for agents the container already provides both per-run instances and async setup.
|
||||
- `HostedWorkflowState`: a thin holder bundling a workflow target with a `CheckpointManager` and an
|
||||
internal `sessionId -> CheckpointInfo` head cursor, exposing `RunOrResumeAsync`. .NET's checkpoint
|
||||
store is already `sessionId`-keyed (unlike Python's workflow-name keying), but `CheckpointInfo` has
|
||||
no ordering, so the holder remembers the head checkpoint per session to resume. On subsequent turns it
|
||||
restores that checkpoint and runs the workflow forward with the new turn's input (mirroring the Python
|
||||
host's restore-then-run semantics), rather than continuing a halted run with no input. When the
|
||||
in-memory cursor misses (new holder / process restart) it reads the session's latest checkpoint from the
|
||||
`CheckpointManager`, so a durable manager resumes across restarts. It accepts either a single workflow
|
||||
instance (which cannot be run by two runners at once, so its turns are processed one at a time) or a
|
||||
workflow factory (`Func<CancellationToken, ValueTask<Workflow>>`). By default the factory builds a fresh
|
||||
instance per run so independent sessions run in parallel; with `cacheWorkflow: true` the factory is invoked
|
||||
once lazily and its result is cached and reused (a deferred, cached target that, like the instance, cannot
|
||||
run concurrent turns). A resume rehydrates an instance from the session's checkpoint in the shared store, so
|
||||
per-run instances still continue the same run; concurrent turns against the same session id remain the
|
||||
application's coordination responsibility.
|
||||
|
||||
### Scope
|
||||
|
||||
Responses only for v1; the facade is named so a parallel `OpenAIChatCompletions` facade can follow.
|
||||
No new package, no OpenAI-SDK-typed reimplementation, no change to `MapOpenAIResponses` public
|
||||
behavior.
|
||||
|
||||
### Security responsibilities
|
||||
|
||||
Consistent with ADR-0027, the application owns the trust boundary. `GetSessionId(...)` returns an
|
||||
untrusted candidate key; the application must authenticate the caller and authorize/bind the id before
|
||||
using it as an `AgentSessionStore` key or workflow checkpoint session id. Multi-user hosts must scope
|
||||
the session store per principal (`IsolationKeyScopedAgentSessionStore`). Helpers stay side-effect-free;
|
||||
persistence happens only after the run completes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Smallest possible surface: the released addition is one facade type plus one thin workflow state
|
||||
holder and one new store method (agents use `AgentSessionStore` directly, no holder).
|
||||
- No duplicated conversion; the app-owned-routing path and the route-owning server share one core.
|
||||
- `MapOpenAIResponses` users are unaffected.
|
||||
|
||||
Negative:
|
||||
|
||||
- The facade's `JsonElement` boundary is less strongly typed than the internal DTOs (accepted to keep
|
||||
the wire model internal and mirror Python's dict boundary).
|
||||
- Workflow resume relies on an in-memory head cursor by default; durable multi-replica hosts must
|
||||
supply their own cursor persistence.
|
||||
|
||||
## More Information
|
||||
|
||||
- Parent ADR: [ADR-0027](0027-hosting-channels.md).
|
||||
- Spec: `docs/specs/003-dotnet-hosting-protocol-helpers.md`.
|
||||
@@ -66,6 +66,8 @@ 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. |
|
||||
@@ -91,6 +93,7 @@ Examples:
|
||||
|
||||
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
|
||||
`responses_session_id(...)`;
|
||||
- `a2a_to_run(...)`, `a2a_from_run(...)`;
|
||||
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
|
||||
`telegram_session_id(...)`, `telegram_command(...)`;
|
||||
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
|
||||
@@ -178,6 +181,9 @@ The target may be:
|
||||
- `await get_target()`;
|
||||
- synchronous `target` only after a target is already available/resolved.
|
||||
|
||||
A workflow instance permits one active run. Concurrent hosts use a factory or
|
||||
builder with `cache_target=False` to resolve a fresh instance per run.
|
||||
|
||||
Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need
|
||||
per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses
|
||||
file-backed cursor storage, the file-based checkpoint storage should share the same app storage root and should be
|
||||
@@ -245,6 +251,100 @@ 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
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-07-08
|
||||
deciders: rogerbarreto
|
||||
consulted: eavanvalkenburg
|
||||
informed: []
|
||||
---
|
||||
|
||||
# .NET hosting: OpenAI Responses protocol helpers and optional execution state
|
||||
|
||||
Implements [ADR-0032](../decisions/0032-dotnet-hosting-protocol-helpers.md), which realizes the
|
||||
helper-first direction of [ADR-0027](../decisions/0027-hosting-channels.md) for .NET.
|
||||
|
||||
## What is the goal of this feature?
|
||||
|
||||
Let application developers expose an `AIAgent` or workflow over the OpenAI Responses protocol **while
|
||||
owning their own ASP.NET Core route, authentication, middleware, and storage**, by calling small,
|
||||
side-effect-free Agent Framework conversion helpers instead of adopting the batteries-included,
|
||||
route-owning `MapOpenAIResponses` server.
|
||||
|
||||
Success: an application can implement a working `POST /responses` endpoint (sync + streaming) in its
|
||||
own minimal-API handler using only the public helpers plus its own auth/storage, with no dependency on
|
||||
`MapOpenAIResponses` or `IResponsesService`.
|
||||
|
||||
## What is the problem being solved?
|
||||
|
||||
.NET already exposes agents as the OpenAI Responses API, but only through the route-owning
|
||||
`MapOpenAIResponses`/`IResponsesService`, which also owns routing, response/conversation storage,
|
||||
streaming, and lifecycle. An application that wants its own routing (custom auth, middleware, status
|
||||
codes, durable storage, or a different framework surface) currently has no supported way to reuse the
|
||||
framework's Responses<->agent conversion. Every conversion primitive that would make this possible
|
||||
already exists in `Microsoft.Agents.AI.Hosting.OpenAI` but is `internal`.
|
||||
|
||||
This feature un-bundles that conversion into a public, app-callable surface, and adds the minimal
|
||||
execution-state helpers an app needs for session continuity and workflow checkpoint resume.
|
||||
|
||||
## API Changes
|
||||
|
||||
### `Microsoft.Agents.AI.Hosting.OpenAI` (new public static facade `OpenAIResponses`)
|
||||
|
||||
Boundary is `System.Text.Json`; the wire DTOs stay internal. All members are side-effect-free.
|
||||
|
||||
```csharp
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI;
|
||||
|
||||
public static class OpenAIResponses
|
||||
{
|
||||
// Wire -> Agent Framework run input.
|
||||
public static OpenAIResponsesRunRequest ToAgentRunRequest(
|
||||
JsonElement body,
|
||||
OpenAIResponsesMapOptions? mapOptions = null);
|
||||
|
||||
// Agent Framework result -> Responses payload (no originating request required).
|
||||
public static JsonElement WriteResponse(
|
||||
AgentResponse response,
|
||||
string responseId,
|
||||
string? sessionId = null);
|
||||
|
||||
// Agent Framework stream -> Responses SSE `data:` frames.
|
||||
public static IAsyncEnumerable<string> WriteResponseStreamAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
string responseId,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Untrusted candidate continuation key: previous_response_id or conversation id (or null).
|
||||
// Kept SEPARATE from ToAgentRunRequest so using a request-derived key is an explicit decision.
|
||||
public static string? GetSessionId(JsonElement body);
|
||||
|
||||
// Mint a `resp_*` id.
|
||||
public static string CreateResponseId();
|
||||
}
|
||||
|
||||
// Result of ToAgentRunRequest.
|
||||
public sealed class OpenAIResponsesRunRequest
|
||||
{
|
||||
public IList<ChatMessage> Messages { get; }
|
||||
public AgentRunOptions? Options { get; }
|
||||
}
|
||||
```
|
||||
|
||||
`ToAgentRunRequest` honors `OpenAIResponsesMapOptions.RunOptionsFactory` exactly as the route model
|
||||
does (by default no request setting is mapped onto the run; unsupported settings surface as a
|
||||
`NotSupportedException`). `WriteResponse`/`WriteResponseStreamAsync` reuse the existing internal
|
||||
`AgentResponseExtensions.ToResponse` / `AgentResponseUpdateExtensions.ToStreamingResponseAsync`
|
||||
converters (an internal `ToResponse` overload with an optional originating request is added so the
|
||||
facade can render without one). The streaming renderer's existing workflow-event support is preserved.
|
||||
|
||||
### `Microsoft.Agents.AI.Hosting` (execution state, protocol-neutral)
|
||||
|
||||
```csharp
|
||||
namespace Microsoft.Agents.AI.Hosting;
|
||||
|
||||
public abstract class AgentSessionStore
|
||||
{
|
||||
// ... existing members ...
|
||||
|
||||
// New: the one missing store operation. Virtual (not abstract) with a default that throws
|
||||
// NotSupportedException, so existing external stores (e.g. the Foundry hosting stores) keep
|
||||
// compiling; the in-box Hosting stores override it. In-box overrides treat deleting a missing
|
||||
// session as a no-op.
|
||||
public virtual ValueTask DeleteSessionAsync(
|
||||
AIAgent agent, string conversationId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// Thin holder: pairs a workflow target with checkpointing + a per-session head cursor.
|
||||
public sealed class HostedWorkflowState
|
||||
{
|
||||
// Shared-instance mode: one instance cannot be run by two runners at once, so turns run one at a time.
|
||||
public HostedWorkflowState(Workflow workflow, CheckpointManager? checkpointManager = null);
|
||||
|
||||
// Factory mode: by default a fresh instance is built per run, so independent sessions run in parallel.
|
||||
// With cacheWorkflow: true the factory is invoked once lazily and the built instance is cached and reused.
|
||||
public HostedWorkflowState(Func<CancellationToken, ValueTask<Workflow>> workflowFactory, CheckpointManager? checkpointManager = null, bool cacheWorkflow = false);
|
||||
|
||||
// First turn runs forward from the start; subsequent turns restore the session's latest
|
||||
// checkpoint and run forward with the new turn's input, then record the new head checkpoint.
|
||||
public ValueTask<HostedWorkflowRunResult> RunOrResumeAsync(
|
||||
string sessionId, object input, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
For agents, the application uses `AgentSessionStore` directly: `GetSessionAsync(agent, id)` creates a
|
||||
session on miss and returns an independent instance per call (so concurrent calls can fork the same
|
||||
stored state — for example branching from a `previous_response_id` or managing several `conversation`
|
||||
ids side by side — without one branch observing another's in-flight mutations). The store performs no
|
||||
cross-call locking; an application that needs concurrent runs against the same id to be serialized owns
|
||||
that coordination. `SaveSessionAsync(agent, id, session)` persists post-run, including under a newly
|
||||
minted `resp_*` id when the protocol mints a new continuation id. `DeleteSessionAsync` uses the new
|
||||
store method. No agent-side holder is needed: create-on-miss already lives in the store, so a
|
||||
pass-through wrapper would only bind the `agent` argument.
|
||||
|
||||
`HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory
|
||||
`sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but
|
||||
`CheckpointInfo` carries no ordering, the holder remembers the head checkpoint per session so
|
||||
`RunOrResumeAsync` can resume the correct one. On subsequent turns it restores that checkpoint to
|
||||
rehydrate accumulated workflow state and then runs the workflow forward with the new turn's input,
|
||||
rather than continuing a halted run with no input (which would wait for input
|
||||
indefinitely). For agent (chat-protocol) workflows the new input is accompanied by a `TurnToken` so the
|
||||
turn is driven. When the in-memory cursor misses (a new holder or a process restart), the holder falls
|
||||
back to `CheckpointManager.GetLatestCheckpointAsync(sessionId)`, so a durable `CheckpointManager` resumes
|
||||
correctly across restarts (the default in-memory manager does not persist, so a restart starts fresh). A
|
||||
resume that produces no events is logged as a warning (possible stale checkpoint or mismatched input).
|
||||
Concurrency depends on how the holder is constructed. With a single shared workflow instance, concurrent runs
|
||||
are not supported, because a workflow instance cannot be run by two runners at once; process turns one at a
|
||||
time. With a workflow factory
|
||||
(`Func<CancellationToken, ValueTask<Workflow>>`) it builds a fresh instance per run by default, so independent
|
||||
sessions run in parallel; a resume rehydrates a fresh instance
|
||||
from the session's checkpoint in the shared store, and concurrent turns against the same session id remain the
|
||||
application's coordination responsibility. Passing `cacheWorkflow: true` instead builds the workflow once,
|
||||
lazily on first use, and reuses it (a deferred, cached target that — like the instance — cannot run concurrent
|
||||
turns). A
|
||||
streaming counterpart, `RunOrResumeStreamingAsync`, yields the turn's `WorkflowEvent`s as they occur (for
|
||||
example to render agent updates over the Responses SSE wire) and records the head checkpoint once the
|
||||
stream is fully enumerated, keeping the blocking and streaming workflow paths in lockstep.
|
||||
Because `RunOrResumeAsync`/`RunOrResumeStreamingAsync` are generic over the input type, the application
|
||||
adapts the Responses input into the workflow's start-executor input type at the call site (for example
|
||||
parsing a structured payload into a typed record), without coupling the holder to a specific wire type.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
- ChatCompletions / Conversations helper surfaces (the facade is named so `OpenAIChatCompletions` can
|
||||
follow).
|
||||
- Changing `MapOpenAIResponses` public behavior.
|
||||
- A new package or an OpenAI-SDK-typed reimplementation.
|
||||
- Durable/pluggable workflow checkpoint-cursor storage (in-memory default only for v1).
|
||||
|
||||
## Security responsibilities (application-owned)
|
||||
|
||||
- Authenticate the caller before using any `GetSessionId(...)` result.
|
||||
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
|
||||
`AgentSessionStore` key or a workflow checkpoint session id.
|
||||
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
|
||||
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
|
||||
- Persist session/checkpoint state only after the run or stream has completed.
|
||||
|
||||
## E2E Code Samples
|
||||
|
||||
### Agent over Responses, app-owned route (non-streaming + SSE)
|
||||
|
||||
```csharp
|
||||
var agent = /* an AIAgent */;
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); // in-memory session store
|
||||
|
||||
app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct);
|
||||
JsonElement body = doc.RootElement;
|
||||
|
||||
// App owns auth + id trust decisions.
|
||||
string? candidate = OpenAIResponses.GetSessionId(body);
|
||||
string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId();
|
||||
|
||||
var run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
var session = await sessionStore.GetSessionAsync(agent, sessionId, ct);
|
||||
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
|
||||
if (body.TryGetProperty("stream", out var s) && s.GetBoolean())
|
||||
{
|
||||
http.Response.ContentType = "text/event-stream";
|
||||
var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, ct);
|
||||
await foreach (var frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, sessionId, ct))
|
||||
{
|
||||
await http.Response.WriteAsync(frame, ct);
|
||||
await http.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var result = await agent.RunAsync(run.Messages, session, run.Options, ct);
|
||||
await sessionStore.SaveSessionAsync(agent, responseId, session, ct);
|
||||
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId));
|
||||
});
|
||||
```
|
||||
|
||||
### Workflow over Responses with checkpoint resume
|
||||
|
||||
Workflow checkpoint resume requires a **stable** session key across turns. `previous_response_id` changes
|
||||
every turn, so it is not a valid checkpoint key; use the `conversation` id (constant for the conversation).
|
||||
Because `GetSessionId(...)` prefers `previous_response_id`, a workflow route reads the conversation id
|
||||
directly rather than calling `GetSessionId(...)`.
|
||||
|
||||
```csharp
|
||||
var state = new HostedWorkflowState(workflow); // in-memory checkpoints + cursor
|
||||
|
||||
app.MapPost("/responses", async (HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(http.Request.Body, cancellationToken: ct);
|
||||
JsonElement body = doc.RootElement;
|
||||
|
||||
// Stable, authorized checkpoint key. GetConversationId(...) reads the conversation id (string or object).
|
||||
string sessionId = Authorize(http.User, GetConversationId(body))
|
||||
?? OpenAIResponses.CreateResponseId();
|
||||
|
||||
var run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
|
||||
// Runs forward on first call, resumes from the session's head checkpoint thereafter.
|
||||
var result = await state.RunOrResumeAsync(sessionId, run.Messages, ct);
|
||||
|
||||
return Results.Json(OpenAIResponses.WriteResponse(result.AsAgentResponse(),
|
||||
OpenAIResponses.CreateResponseId(), sessionId));
|
||||
});
|
||||
```
|
||||
@@ -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.8" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.9" />
|
||||
<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.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" 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.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="9.7.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.InMemory" Version="1.0.0" />
|
||||
<PackageVersion Include="CommunityToolkit.VectorData.Qdrant" Version="1.0.0" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.5" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
@@ -122,6 +122,7 @@
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Dapr.AI.Microsoft.Extensions" Version="1.18.4" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<Project Path="samples/02-agents/AgentProviders/azure/Agent_With_AzureOpenAIChatCompletion/Agent_With_AzureOpenAIChatCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/azure/Agent_With_AzureOpenAIResponses/Agent_With_AzureOpenAIResponses.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/custom/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/Agent_With_GitHubCopilot.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/google-gemini/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/ollama/Agent_With_Ollama/Agent_With_Ollama.csproj" />
|
||||
@@ -200,6 +201,7 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
|
||||
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
|
||||
@@ -313,7 +315,19 @@
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowExpectedOutputs/Evaluation_WorkflowExpectedOutputs.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/">
|
||||
<Folder Name="/Samples/04-hosting/" />
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/">
|
||||
<File Path="samples/04-hosting/af-hosting/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/local_responses/">
|
||||
<File Path="samples/04-hosting/af-hosting/local_responses/README.md" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses/Server/Server.csproj" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses/Client/Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/af-hosting/local_responses_workflow/">
|
||||
<File Path="samples/04-hosting/af-hosting/local_responses_workflow/README.md" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Server/Server.csproj" />
|
||||
<Project Path="samples/04-hosting/af-hosting/local_responses_workflow/Client/Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/" />
|
||||
@@ -639,7 +653,9 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/" />
|
||||
<Folder Name="/Tests/">
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/IntegrationTests/">
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
|
||||
@@ -427,6 +427,15 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "AgentWithMemory_Step06_MemoryUsingAgentMemory",
|
||||
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_API_KEY", "FOUNDRY_MODEL", "FOUNDRY_EMBEDDING_MODEL", "NEO4J_URI", "NEO4J_USER", "NEO4J_PASSWORD"],
|
||||
SkipReason = "Requires a running Neo4j instance; standalone sample outside the repo's CPM build.",
|
||||
},
|
||||
|
||||
// ── AgentWithRAG ────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.13.0</VersionPrefix>
|
||||
<VersionPrefix>1.15.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260703</DateSuffix>
|
||||
<DateSuffix>260722</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.13.0</GitTag>
|
||||
<GitTag>1.15.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -44,6 +44,12 @@ See the README.md for each sample for the prerequisites for that sample.
|
||||
| --- | --- |
|
||||
| [Custom Implementation](./custom/Agent_With_CustomImplementation/) | Create an AIAgent with a custom implementation |
|
||||
|
||||
### [Dapr](./dapr/)
|
||||
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [Agent with Dapr](./dapr/Agent_With_Dapr/) | Create an AIAgent using Dapr's Conversation building block as the inference backend |
|
||||
|
||||
### [Foundry](./foundry/)
|
||||
|
||||
See [foundry/README.md](./foundry/README.md) for the full list of Foundry agent samples,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);DAPR_CONVERSATION</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapr.AI.Microsoft.Extensions" />
|
||||
<!--
|
||||
Dapr.AI.Microsoft.Extensions depends on Microsoft.Extensions.* 10.0.8, which is higher than the
|
||||
versions pinned centrally in Directory.Packages.props. Central transitive pinning is disabled above
|
||||
for this sample and these two direct references are overridden to that minimum. Remove the overrides
|
||||
(and the CentralPackageTransitivePinningEnabled setting) once the central versions are >= 10.0.8.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" VersionOverride="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" VersionOverride="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
apiVersion: dapr.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: ollama
|
||||
spec:
|
||||
type: conversation.ollama
|
||||
metadata:
|
||||
- name: model
|
||||
value: llama3.2
|
||||
- name: cacheTTL
|
||||
value: 10m
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with Dapr as the backend.
|
||||
// Dapr's Conversation building block is used here to route inference to Ollama.
|
||||
|
||||
using Dapr.AI.Conversation.Extensions;
|
||||
using Dapr.AI.Microsoft.Extensions;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
// The Dapr sidecar's gRPC endpoint. This must match the --dapr-grpc-port used when starting
|
||||
// the sidecar (see this sample's README). Override it with the DAPR_GRPC_ENDPOINT environment
|
||||
// variable if you run the sidecar on a different port.
|
||||
var daprGrpcEndpoint = Environment.GetEnvironmentVariable("DAPR_GRPC_ENDPOINT") ?? "http://localhost:3501";
|
||||
|
||||
// Register the Dapr Conversation client with dependency injection.
|
||||
var app = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// Configure the gRPC endpoint for the Dapr sidecar.
|
||||
services.AddDaprConversationClient((_, builder) => builder.UseGrpcEndpoint(daprGrpcEndpoint));
|
||||
// Provide the name of the Conversation component loaded in the sidecar to use.
|
||||
services.AddDaprChatClient(opt => opt.ConversationComponentName = "ollama");
|
||||
}).Build();
|
||||
|
||||
// Get an instance of the Dapr chat client from the dependency injection container.
|
||||
using var scope = app.Services.CreateScope();
|
||||
var daprChatClient = scope.ServiceProvider.GetRequiredService<IChatClient>();
|
||||
|
||||
// Use this chat client to construct an AIAgent.
|
||||
AIAgent agent = daprChatClient.AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate."));
|
||||
@@ -0,0 +1,37 @@
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Docker installed and running on your machine
|
||||
- Ollama installed
|
||||
- Dapr CLI installed ([instructions](https://docs.dapr.io/getting-started/install-dapr-cli/))
|
||||
|
||||
You'll need to download a model from [Ollama's library](https://ollama.com/library) to get started. Open
|
||||
a terminal and run the following, replacing `<model_name>` with the name of the model you want to use from
|
||||
Ollama's library (e.g., `llama3.2`).
|
||||
|
||||
```powershell
|
||||
ollama run <model_name>
|
||||
```
|
||||
|
||||
Once it has downloaded and started running, update the component bundled with this example
|
||||
in `./Components/conversation-ollama.yaml` to reflect the name of the model you just installed, modifying the value of
|
||||
the `model` metadata property, then save your changes and close the file.
|
||||
|
||||
Next, start your Dapr sidecar and tell it where it can look for your components. If launching from this project's directory,
|
||||
run the following; otherwise, replace `./Components` with the path to your components directory.
|
||||
|
||||
```powershell
|
||||
dapr run --app-id agents --resources-path ./Components --dapr-grpc-port 3501
|
||||
```
|
||||
|
||||
The sample connects to the sidecar at `http://localhost:3501` by default. If you start the sidecar on a
|
||||
different gRPC port, set the `DAPR_GRPC_ENDPOINT` environment variable to match before running the sample.
|
||||
|
||||
Because the Dapr sidecar needs to continue running while your application is running, please open another terminal
|
||||
window and run the following command from this project's directory to start the demo.
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.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="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.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
@@ -0,0 +1,78 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
This project is part of the repo's solution and targets .NET 10 like the rest of the repo, but it
|
||||
intentionally opts out of Central Package Management and source-referencing Microsoft.Agents.AI:
|
||||
it consumes the *published* AgentMemory NuGet packages (which target Microsoft.Agents.AI 1.9.0)
|
||||
instead. Run it with `dotnet run` from this folder.
|
||||
|
||||
ManagePackageVersionsCentrally is off, but dotnet/Directory.Packages.props still unconditionally
|
||||
merges its repo-wide analyzer PackageReference items (no Version, resolved via CPM) into every
|
||||
project that imports it — including this one. With CPM off here those versions can't resolve
|
||||
(NU1015), so each is removed and re-added with an explicit version below (matching
|
||||
AgentWithRAG_Step05_Neo4jGraphRAG, which hits the same issue). xunit.analyzers/Moq.Analyzers are
|
||||
dropped rather than re-added since this project has no test code.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<RootNamespace>AgentMemoryShoppingAssistant</RootNamespace>
|
||||
<!-- OPENAI001: the OpenAIClient(AuthenticationPolicy, options) ctor used for keyless Azure auth is
|
||||
marked experimental in the OpenAI SDK (the MAF Foundry samples use the same pattern). -->
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
|
||||
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
|
||||
<PackageReference Remove="xunit.analyzers" />
|
||||
<PackageReference Remove="Moq.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
|
||||
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
|
||||
Microsoft Agent Framework adapter. -->
|
||||
<PackageReference Include="AgentMemory" Version="1.2.0" />
|
||||
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
|
||||
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.17" />
|
||||
<!-- Transitive dependency of Microsoft.Agents.AI; pinned explicitly (CPM is off here) because the
|
||||
version it would otherwise resolve to, 1.12.0, has a known moderate severity vulnerability
|
||||
(GHSA-g94r-2vxg-569j) that fails the repo's NuGet audit (NU1902 as error). Matches the version
|
||||
pinned in dotnet/Directory.Packages.props. -->
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using AgentMemory.Neo4j.Infrastructure;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Neo4j.Driver;
|
||||
|
||||
namespace AgentMemoryShoppingAssistant;
|
||||
|
||||
/// <summary>
|
||||
/// A small retail product graph plus the shopping tools that query it — the .NET counterpart of the
|
||||
/// Python retail-assistant's <c>get_product_tools</c>. Products live in Neo4j as <c>:Product</c> nodes
|
||||
/// linked to <c>:ProductCategory</c> / <c>:ProductBrand</c> nodes, so recommendations and "related
|
||||
/// products" come from graph traversals. Cypher runs through the public <see cref="INeo4jTransactionRunner"/>
|
||||
/// seam. Exposed as <see cref="AIFunction"/>s so a real chat model can call them during a run — the same
|
||||
/// way <c>Neo4jMemoryContextProvider</c> surfaces the memory tools through <c>AIContext.Tools</c> when
|
||||
/// <c>ExposeMemoryToolsFromContextProvider</c> is enabled.
|
||||
/// </summary>
|
||||
public sealed class ProductCatalog(INeo4jTransactionRunner runner)
|
||||
{
|
||||
private readonly INeo4jTransactionRunner _runner = runner;
|
||||
|
||||
private static readonly (string Name, string Category, string Brand, double Price, bool InStock, int Inventory, string Description, int Popularity)[] s_seed =
|
||||
[
|
||||
("Nike Air Zoom Pegasus 40", "shoes", "Nike", 130, true, 40, "Everyday running shoe with responsive cushioning.", 95),
|
||||
("Nike Revolution 7", "shoes", "Nike", 70, true, 60, "Lightweight, budget-friendly running shoe.", 80),
|
||||
("Adidas Ultraboost Light", "shoes", "Adidas", 190, true, 25, "Premium running shoe with Boost cushioning.", 90),
|
||||
("Asics Gel-Kayano 31", "shoes", "Asics", 165, false, 0, "Stability running shoe for overpronation.", 70),
|
||||
("Sony WH-1000XM5", "electronics", "Sony", 350, true, 18, "Industry-leading noise-cancelling headphones.", 92),
|
||||
("Bose QuietComfort Ultra", "electronics", "Bose", 330, true, 12, "Premium noise-cancelling over-ear headphones.", 85),
|
||||
("Apple AirPods Pro 2", "electronics", "Apple", 250, true, 50, "Wireless earbuds with active noise cancellation.", 88),
|
||||
("Garmin Forerunner 265", "electronics", "Garmin", 450, true, 9, "GPS running watch with training metrics.", 78),
|
||||
("Nike Dri-FIT Running Tee", "apparel", "Nike", 35, true, 120, "Breathable, moisture-wicking running shirt.", 65),
|
||||
("Adidas Own the Run Jacket","apparel", "Adidas", 80, true, 33, "Lightweight, water-repellent running jacket.", 60),
|
||||
];
|
||||
|
||||
/// <summary>Seeds the sample product graph (idempotent — safe to run every start).</summary>
|
||||
public Task SeedAsync(CancellationToken ct = default) => this._runner.WriteAsync(async r =>
|
||||
{
|
||||
await r.RunAsync(
|
||||
"""
|
||||
UNWIND $products AS row
|
||||
MERGE (p:Product {name: row.name})
|
||||
SET p.category = row.category, p.brand = row.brand, p.price = row.price,
|
||||
p.in_stock = row.in_stock, p.inventory = row.inventory,
|
||||
p.description = row.description, p.popularity = row.popularity
|
||||
MERGE (c:ProductCategory {name: row.category})
|
||||
MERGE (b:ProductBrand {name: row.brand})
|
||||
MERGE (p)-[:IN_CATEGORY]->(c)
|
||||
MERGE (p)-[:MADE_BY]->(b)
|
||||
""",
|
||||
new
|
||||
{
|
||||
products = s_seed.Select(p => (object)new Dictionary<string, object>
|
||||
{
|
||||
["name"] = p.Name, ["category"] = p.Category, ["brand"] = p.Brand, ["price"] = p.Price,
|
||||
["in_stock"] = p.InStock, ["inventory"] = p.Inventory, ["description"] = p.Description,
|
||||
["popularity"] = p.Popularity,
|
||||
}).ToList(),
|
||||
});
|
||||
}, ct);
|
||||
|
||||
// ── Tools (also usable directly in the scripted demo) ────────────────────────────────────────
|
||||
|
||||
[Description("Search the product catalog for items matching a query, with optional category, brand, and max-price filters.")]
|
||||
public Task<string> SearchProductsAsync(
|
||||
[Description("What the customer is looking for, e.g. 'running shoes'.")] string query,
|
||||
[Description("Optional category filter: shoes, electronics, apparel.")] string? category = null,
|
||||
[Description("Optional brand filter, e.g. 'Nike'.")] string? brand = null,
|
||||
[Description("Optional maximum price.")] double? maxPrice = null,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
const string Cypher =
|
||||
"""
|
||||
MATCH (p:Product)
|
||||
WHERE ANY(w IN split(toLower($query), ' ') WHERE
|
||||
toLower(p.name) CONTAINS w OR toLower(p.description) CONTAINS w OR toLower(p.category) CONTAINS w)
|
||||
AND ($category IS NULL OR p.category = $category)
|
||||
AND ($brand IS NULL OR p.brand = $brand)
|
||||
AND ($maxPrice IS NULL OR p.price <= $maxPrice)
|
||||
RETURN p.name AS name, p.brand AS brand, p.category AS category,
|
||||
p.price AS price, p.in_stock AS inStock
|
||||
ORDER BY p.popularity DESC
|
||||
LIMIT 10
|
||||
""";
|
||||
var cursor = await r.RunAsync(Cypher, new { query, category, brand, maxPrice });
|
||||
return Render("Matches", await cursor.ToListAsync());
|
||||
}, ct);
|
||||
|
||||
[Description("Get personalized product recommendations, optionally biased toward a preferred brand and/or category.")]
|
||||
public Task<string> GetRecommendationsAsync(
|
||||
[Description("The customer's preferred brand (from their saved preferences), if known.")] string? preferredBrand = null,
|
||||
[Description("Optional category to recommend within.")] string? category = null,
|
||||
[Description("How many recommendations to return.")] int limit = 5,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
const string Cypher =
|
||||
"""
|
||||
MATCH (p:Product)
|
||||
WHERE p.in_stock = true
|
||||
AND ($category IS NULL OR p.category = $category)
|
||||
WITH p, (CASE WHEN $preferredBrand IS NOT NULL AND p.brand = $preferredBrand THEN 1 ELSE 0 END) AS onBrand
|
||||
RETURN p.name AS name, p.brand AS brand, p.category AS category, p.price AS price, p.in_stock AS inStock
|
||||
ORDER BY onBrand DESC, p.popularity DESC
|
||||
LIMIT $limit
|
||||
""";
|
||||
var cursor = await r.RunAsync(Cypher, new { preferredBrand, category, limit });
|
||||
var header = preferredBrand is null ? "Recommended for you" : $"Recommended for you (favoring {preferredBrand})";
|
||||
return Render(header, await cursor.ToListAsync());
|
||||
}, ct);
|
||||
|
||||
[Description("Find products related to a given product — same category or same brand — via graph traversal.")]
|
||||
public Task<string> GetRelatedProductsAsync(
|
||||
[Description("The exact product name to find related items for.")] string productName,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
const string Cypher =
|
||||
"""
|
||||
MATCH (p:Product {name: $productName})
|
||||
CALL (p) {
|
||||
MATCH (p)-[:IN_CATEGORY]->(c)<-[:IN_CATEGORY]-(rel:Product) WHERE rel <> p
|
||||
RETURN rel, 'same category' AS reason
|
||||
UNION
|
||||
MATCH (p)-[:MADE_BY]->(b)<-[:MADE_BY]-(rel:Product) WHERE rel <> p
|
||||
RETURN rel, 'same brand' AS reason
|
||||
}
|
||||
WITH rel, collect(DISTINCT reason) AS reasons
|
||||
RETURN rel.name AS name, rel.brand AS brand, rel.category AS category,
|
||||
rel.price AS price, rel.in_stock AS inStock, rel.popularity AS popularity,
|
||||
reduce(s = '', x IN reasons | CASE WHEN s = '' THEN x ELSE s + ', ' + x END) AS reason
|
||||
ORDER BY popularity DESC
|
||||
LIMIT 5
|
||||
""";
|
||||
var cursor = await r.RunAsync(Cypher, new { productName });
|
||||
return Render($"Related to {productName}", await cursor.ToListAsync());
|
||||
}, ct);
|
||||
|
||||
[Description("Check whether a product is in stock and how many units are available.")]
|
||||
public Task<string> CheckInventoryAsync(
|
||||
[Description("The exact product name to check.")] string productName,
|
||||
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
|
||||
{
|
||||
var cursor = await r.RunAsync(
|
||||
"MATCH (p:Product {name: $productName}) RETURN p.name AS name, p.in_stock AS inStock, p.inventory AS inventory",
|
||||
new { productName });
|
||||
var rows = await cursor.ToListAsync();
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return $"'{productName}' was not found in the catalog.";
|
||||
}
|
||||
|
||||
var rec = rows[0];
|
||||
var inStock = rec["inStock"].As<bool>();
|
||||
return inStock
|
||||
? $"{rec["name"].As<string>()}: In stock ({rec["inventory"].As<long>()} available)."
|
||||
: $"{rec["name"].As<string>()}: Out of stock.";
|
||||
}, ct);
|
||||
|
||||
/// <summary>The retail tools as MAF/MEAI <see cref="AIFunction"/>s (attach to the agent's ChatOptions.Tools).</summary>
|
||||
public IReadOnlyList<AIFunction> CreateAIFunctions() =>
|
||||
[
|
||||
AIFunctionFactory.Create(this.SearchProductsAsync, "search_products",
|
||||
"Search the product catalog with optional category/brand/price filters."),
|
||||
AIFunctionFactory.Create(this.GetRecommendationsAsync, "get_recommendations",
|
||||
"Get personalized recommendations, optionally favoring a preferred brand/category."),
|
||||
AIFunctionFactory.Create(this.GetRelatedProductsAsync, "get_related_products",
|
||||
"Find products related to a given product via the graph."),
|
||||
AIFunctionFactory.Create(this.CheckInventoryAsync, "check_inventory",
|
||||
"Check stock/availability for a product."),
|
||||
];
|
||||
|
||||
private static string Render(string header, List<IRecord> rows)
|
||||
{
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return $"{header}: (no matches)";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder().Append(header).Append(':').AppendLine();
|
||||
foreach (var rec in rows)
|
||||
{
|
||||
var stock = rec["inStock"].As<bool>() ? "in stock" : "out of stock";
|
||||
var reason = rec.Keys.Contains("reason") ? $" [{rec["reason"].As<string>()}]" : string.Empty;
|
||||
sb.Append(" • ")
|
||||
.Append(rec["name"].As<string>())
|
||||
.Append(" — ").Append(rec["brand"].As<string>())
|
||||
.Append(", ").Append(rec["category"].As<string>())
|
||||
.Append(", $").Append(rec["price"].As<double>().ToString("0"))
|
||||
.Append(", ").Append(stock).Append(reason)
|
||||
.AppendLine();
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Agent Memory — Shopping Assistant (Microsoft Agent Framework, .NET)
|
||||
//
|
||||
// A .NET port of the Neo4j Labs "agent-memory" retail-assistant example
|
||||
// (https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant,
|
||||
// referenced from https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory).
|
||||
//
|
||||
// A shopping assistant that LEARNS a customer's preferences and RECOMMENDS products via graph
|
||||
// traversal, backed by DURABLE memory in Neo4j. It uses the AgentMemory library — a .NET port of the
|
||||
// Python memory provider, not an officially recognized Neo4j integration — and its Microsoft Agent
|
||||
// Framework adapter:
|
||||
// • Neo4jMemoryContextProvider (an AIContextProvider) — recalls memory before each run, persists
|
||||
// after, and (via ExposeMemoryToolsFromContextProvider) surfaces the memory tools (search/remember/
|
||||
// recall) itself through AIContext.Tools
|
||||
// • ProductCatalog.CreateAIFunctions() — retail tools over a Neo4j :Product graph
|
||||
//
|
||||
// Configuration (environment variables, matching the other Foundry samples):
|
||||
// AZURE_OPENAI_ENDPOINT (required) — your Azure OpenAI / Foundry endpoint
|
||||
// AZURE_OPENAI_API_KEY (optional) — API key; if unset, DefaultAzureCredential (az login) is used
|
||||
// FOUNDRY_MODEL (default: gpt-4o-mini) — chat model deployment
|
||||
// FOUNDRY_EMBEDDING_MODEL (default: text-embedding-3-small) — embedding model deployment (1536 dims)
|
||||
// NEO4J_URI (default: bolt://localhost:7687)
|
||||
// NEO4J_USER (default: neo4j)
|
||||
// NEO4J_PASSWORD (default: password)
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using AgentMemory.Abstractions.Services;
|
||||
using AgentMemory.AgentFramework;
|
||||
using AgentMemory.Core;
|
||||
using AgentMemory.Core.Stubs;
|
||||
using AgentMemory.Neo4j.Infrastructure;
|
||||
using AgentMemoryShoppingAssistant;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI;
|
||||
|
||||
// ── Model + credentials (Azure OpenAI / Foundry, via env vars) ───────────────────────────────────
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
|
||||
var chatModel = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini";
|
||||
var embeddingModel = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL") ?? "text-embedding-3-small";
|
||||
|
||||
var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(endpoint) };
|
||||
// API key if provided, otherwise Azure credential (dev: `az login`).
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
OpenAIClient openAI = string.IsNullOrWhiteSpace(apiKey)
|
||||
? new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
|
||||
: new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions);
|
||||
|
||||
IChatClient chatClient = openAI.GetChatClient(chatModel).AsIChatClient();
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator =
|
||||
openAI.GetEmbeddingClient(embeddingModel).AsIEmbeddingGenerator();
|
||||
|
||||
// ── AgentMemory (Neo4j) DI ───────────────────────────────────────────────────────────────────────
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
||||
|
||||
builder.Services.AddNeo4jAgentMemory(options =>
|
||||
{
|
||||
options.Uri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? "bolt://localhost:7687";
|
||||
options.Username = Environment.GetEnvironmentVariable("NEO4J_USER") ?? "neo4j";
|
||||
options.Password = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? "password";
|
||||
});
|
||||
builder.Services.AddAgentMemoryCore(_ => { });
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<IIdGenerator, GuidIdGenerator>();
|
||||
builder.Services.TryAddSingleton(chatClient);
|
||||
builder.Services.TryAddSingleton(embeddingGenerator);
|
||||
builder.Services.AddAgentMemoryFramework(options =>
|
||||
{
|
||||
options.AutoExtractOnPersist = true;
|
||||
options.ContextFormat.IncludeEntities = true;
|
||||
options.ContextFormat.IncludeFacts = true;
|
||||
options.ContextFormat.IncludePreferences = true;
|
||||
options.ExposeMemoryToolsFromContextProvider = true;
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
await using var hostDisposal = (IAsyncDisposable)host;
|
||||
|
||||
await using var scope = host.Services.CreateAsyncScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
// ── Setup: schema + sample product graph ─────────────────────────────────────────────────────────
|
||||
var catalog = new ProductCatalog(sp.GetRequiredService<INeo4jTransactionRunner>());
|
||||
await sp.GetRequiredService<ISchemaBootstrapper>().BootstrapAsync();
|
||||
await catalog.SeedAsync();
|
||||
Console.WriteLine("Neo4j schema ready; sample products loaded.\n");
|
||||
|
||||
// ── The shopping assistant: context provider (recall + memory tools) + product tools ─────────────
|
||||
var memoryProvider = sp.GetRequiredService<Neo4jMemoryContextProvider>();
|
||||
var productTools = catalog.CreateAIFunctions();
|
||||
|
||||
// WithMemoryOwnerScoping(sp) scopes the whole invocation (recall, tool calls, persistence) to the
|
||||
// owner set via WithMemoryIdentity below — no manual BeginOwnerScope wrapping needed per turn.
|
||||
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = chatModel,
|
||||
Instructions =
|
||||
"You are a helpful shopping assistant for an online store. Learn and remember the customer's "
|
||||
+ "preferences (brands, budget, categories) using the memory tools, and recommend products that "
|
||||
+ "fit using the product tools. Explain why each recommendation matches, and suggest alternatives "
|
||||
+ "when something is out of stock.",
|
||||
// memoryProvider appends the six memory tools (search_memory, remember_fact, ...) to this list
|
||||
// on every model call via AIContext.Tools — see ExposeMemoryToolsFromContextProvider above.
|
||||
Tools = [.. productTools],
|
||||
},
|
||||
AIContextProviders = [memoryProvider],
|
||||
}).WithMemoryOwnerScoping(sp);
|
||||
|
||||
const string Shopper = "shopper-amelia";
|
||||
|
||||
// ── Session A — the customer shops; the model calls the tools and remembers preferences ──────────
|
||||
Console.WriteLine(">> Session A\n");
|
||||
var sessionA = (await agent.CreateSessionAsync())
|
||||
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-a", applicationId: "retail-demo");
|
||||
|
||||
foreach (var turn in new[]
|
||||
{
|
||||
"Hi! I'm looking for running shoes. I love Nike and want to stay under $150.",
|
||||
"Nice — what would you recommend for me, and is anything I might like out of stock?",
|
||||
})
|
||||
{
|
||||
await SayAsync(agent, sessionA, turn);
|
||||
}
|
||||
|
||||
// ── Session B — a NEW session for the same shopper still recalls her preferences ─────────────────
|
||||
Console.WriteLine(">> Session B — a brand-new session; memory is durable\n");
|
||||
var sessionB = (await agent.CreateSessionAsync())
|
||||
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-b", applicationId: "retail-demo");
|
||||
|
||||
await SayAsync(agent, sessionB, "I'm back — remind me what I like and suggest something new.");
|
||||
|
||||
Console.WriteLine("=== Done. Preferences + messages persist in Neo4j across sessions. ===");
|
||||
|
||||
// One conversational turn. Owner scoping (recall, tool calls, and persistence) is guaranteed
|
||||
// automatically by the WithMemoryOwnerScoping-wrapped agent — no manual BeginOwnerScope needed here.
|
||||
static async Task SayAsync(AIAgent agent, AgentSession session, string message)
|
||||
{
|
||||
Console.WriteLine($"USER : {message}");
|
||||
var response = await agent.RunAsync(message, session);
|
||||
Console.WriteLine($"ASSISTANT : {response.Text}\n");
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Agent with Memory Using AgentMemory — Shopping Assistant
|
||||
|
||||
A **.NET port of the Neo4j Labs "agent-memory" retail assistant** example
|
||||
([`microsoft_agent_retail_assistant`](https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant),
|
||||
referenced from the [Learn integration page](https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory)).
|
||||
A shopping assistant that **learns a customer's preferences** and **recommends products via graph
|
||||
traversal**, backed by durable memory in Neo4j.
|
||||
|
||||
It uses the [`AgentMemory`](https://www.nuget.org/packages/AgentMemory) library — a .NET port of the
|
||||
(Python-only) Neo4j Labs memory provider, **not an officially recognized Neo4j integration** — through
|
||||
its Microsoft Agent Framework adapter.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
- **`Neo4jMemoryContextProvider`** (an `AIContextProvider`) — recalls relevant memory before each run,
|
||||
persists new memory after (the same bidirectional pattern as the official provider), and — via
|
||||
`ExposeMemoryToolsFromContextProvider = true` — surfaces the memory tools (search / remember / recall)
|
||||
itself through `AIContext.Tools`.
|
||||
- **`ProductCatalog.CreateAIFunctions()`** — retail tools over a Neo4j `:Product` graph (search /
|
||||
recommend / related / inventory).
|
||||
- Preference learning that persists across a brand-new `AgentSession` for the same shopper.
|
||||
- Graph-based product recommendations and "related products" via traversal.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- A **Neo4j 5.x** instance (the sample bootstraps the schema and seeds sample products)
|
||||
- An **Azure OpenAI / Foundry** deployment (a chat model + an embedding model)
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Required | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `AZURE_OPENAI_ENDPOINT` | ✅ | — | Azure OpenAI / Foundry endpoint |
|
||||
| `AZURE_OPENAI_API_KEY` | — | — | API key; if unset, `DefaultAzureCredential` (`az login`) is used |
|
||||
| `FOUNDRY_MODEL` | — | `gpt-4o-mini` | chat model deployment |
|
||||
| `FOUNDRY_EMBEDDING_MODEL` | — | `text-embedding-3-small` | embedding model deployment (1536 dims) |
|
||||
| `NEO4J_URI` | — | `bolt://localhost:7687` | Neo4j bolt URI |
|
||||
| `NEO4J_USER` | — | `neo4j` | Neo4j user |
|
||||
| `NEO4J_PASSWORD` | — | `password` | Neo4j password |
|
||||
|
||||
> Ensure the embedding model's dimensions match the Neo4j vector-index dimensions AgentMemory bootstraps
|
||||
> (default 1536, which matches `text-embedding-3-small`).
|
||||
|
||||
## Run the Sample
|
||||
|
||||
```bash
|
||||
docker run -d --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5.26
|
||||
|
||||
export AZURE_OPENAI_ENDPOINT="https://<your-resource>.openai.azure.com"
|
||||
export AZURE_OPENAI_API_KEY="<your-key>" # or omit and `az login`
|
||||
export FOUNDRY_MODEL="gpt-4o-mini"
|
||||
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
1. The sample bootstraps the Neo4j schema and seeds a small product graph (`:Product`,
|
||||
`:ProductCategory`, `:ProductBrand` nodes).
|
||||
2. **Session A** — the shopper says she wants running shoes, loves Nike, and has a $150 budget; the
|
||||
agent calls the memory tools to remember this and the product tools to recommend matching items.
|
||||
3. **Session B** — a brand-new session for the same shopper (`shopper-amelia`) still recalls her
|
||||
preferences and can suggest something new, because memory persists in Neo4j across sessions.
|
||||
|
||||
## Note on packaging
|
||||
|
||||
This sample is part of the repo's solution and targets .NET 10 like every other sample, but it
|
||||
deliberately opts out of **Central Package Management** and does **not** reference `Microsoft.Agents.AI`
|
||||
via the repo's in-source project — it consumes the **published** `AgentMemory` NuGet packages instead
|
||||
(which target `Microsoft.Agents.AI` 1.9.0). A version that references the repo's current
|
||||
`Microsoft.Agents.AI` source would require AgentMemory to be rebuilt against that version first.
|
||||
@@ -9,6 +9,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|
||||
|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.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="Microsoft.SemanticKernel.Connectors.Qdrant" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.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="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
<PackageReference Include="CommunityToolkit.VectorData.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;
|
||||
|
||||
|
||||
+7
-5
@@ -133,7 +133,7 @@ AIAgent researchAgent = ResearchAgent.Create(chatClient);
|
||||
// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors
|
||||
// every command to the vault, and the deny-list policy pre-filters obviously destructive commands.
|
||||
// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.)
|
||||
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
await using var shellExecutor = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
{
|
||||
WorkingDirectory = vaultDir,
|
||||
ConfineWorkingDirectory = true,
|
||||
@@ -160,7 +160,9 @@ using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptio
|
||||
// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the
|
||||
// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and
|
||||
// CodeAct.
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
|
||||
// The shell is wired up in two parts: the ShellEnvironmentProvider injects OS/shell/CWD info into the
|
||||
// system prompt, and the shell tool is registered below in ChatOptions.
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct, new ShellEnvironmentProvider(shellExecutor)];
|
||||
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
@@ -170,8 +172,6 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
DisableAgentSkillsProvider = true,
|
||||
// Fan-out research is delegated to this background agent.
|
||||
BackgroundAgents = [researchAgent],
|
||||
// The confined shell, exposed as the approval-gated run_shell tool.
|
||||
ShellExecutor = shell,
|
||||
// Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt.
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
@@ -179,7 +179,7 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
},
|
||||
// Start in "execute" mode for quick lookups and actions; switch any time with /mode plan.
|
||||
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
|
||||
// Our skills provider plus CodeAct.
|
||||
// Our skills provider, CodeAct, and the shell environment provider.
|
||||
AIContextProviders = contextProviders,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
@@ -188,6 +188,8 @@ AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
[
|
||||
StockTools.CreateGetStockPriceTool(),
|
||||
TradingTools.CreatePlaceTradeTool(),
|
||||
// The confined shell, exposed as the approval-gated run_shell tool.
|
||||
shellExecutor.AsAIFunction(requireApproval: true),
|
||||
],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MAAIW001;OPENAI001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -65,6 +65,17 @@ 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)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Framework hosting samples (bring your own route)
|
||||
|
||||
These samples show how to expose an Agent Framework agent or workflow over the OpenAI Responses HTTP
|
||||
protocol from an ASP.NET Core app that you write, where your app owns the HTTP route, authentication, and
|
||||
where conversations are stored.
|
||||
|
||||
## Two ways to expose an agent over the Responses protocol
|
||||
|
||||
Agent Framework gives you two options:
|
||||
|
||||
1. **`MapOpenAIResponses` (batteries included).** A single call maps a ready-made `/responses` endpoint that
|
||||
handles the protocol, routing, and session storage for you. Pick this when you want a working endpoint
|
||||
quickly and the built-in behavior fits. See [AgentWebChat](../../05-end-to-end/AgentWebChat) for a sample
|
||||
that uses it.
|
||||
|
||||
2. **Call the conversion helpers from your own route (these samples).** You write the ASP.NET Core route and
|
||||
call the `OpenAIResponses` helper methods to translate between the Responses HTTP payloads and the agent.
|
||||
The framework only does the protocol translation, so you keep full control of routing, authentication,
|
||||
and where conversations are stored. Pick this when you need hosting behavior the built-in endpoint does
|
||||
not provide.
|
||||
|
||||
## Samples
|
||||
|
||||
| Sample | What it shows |
|
||||
|---|---|
|
||||
| [`local_responses/`](./local_responses) | An agent behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods plus `AgentSessionStore` for conversation continuity. The simplest sample to start with. |
|
||||
| [`local_responses_workflow/`](./local_responses_workflow) | A workflow behind an ASP.NET Core route you write, using the `OpenAIResponses` helper methods, `HostedWorkflowState`, an explicit `CheckpointManager`, and a checkpoint cursor your app keeps so a run resumes across turns. |
|
||||
|
||||
Each sample is a **client/server pair** split into two projects:
|
||||
|
||||
```
|
||||
local_responses/
|
||||
├── Server/ # exposes POST /responses using the OpenAIResponses helper methods
|
||||
└── Client/ # consumes it two ways: a chat client and an agent
|
||||
```
|
||||
|
||||
The `Client` shows the two ways to consume the endpoint from .NET, both against the same server:
|
||||
|
||||
- A plain `Microsoft.Extensions.AI.IChatClient` (the lower-level chat-client path).
|
||||
- A Microsoft Agent Framework `AIAgent` (the higher-level agent path).
|
||||
|
||||
## Relationship to `../FoundryHostedAgents/`
|
||||
|
||||
The sibling [`../FoundryHostedAgents/`](../FoundryHostedAgents) directory contains samples for agents that
|
||||
run inside the Foundry Hosted Agents platform, which hosts the agent and exposes the protocol for you. Use
|
||||
those when you want the Foundry-managed hosting surface; use these when you want to host the agent in your
|
||||
own ASP.NET Core app.
|
||||
|
||||
| Aspect | `af-hosting/` (this directory) | `FoundryHostedAgents/` |
|
||||
|---|---|---|
|
||||
| Server stack | An ASP.NET Core app you write plus the hosting protocol helpers | Foundry Hosted Agents runtime |
|
||||
| Who exposes the route | Your app | The platform |
|
||||
| When to pick this | You need custom hosting code | You want the Foundry-managed hosting surface |
|
||||
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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 }'
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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}\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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_..." }'
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
<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, Foundry)
|
||||
│ ├── AgentWithMemory/ # Memory providers (chat history, Mem0, Valkey, Foundry, AgentMemory)
|
||||
│ ├── AgentWithRAG/ # RAG patterns (text, vector store, Foundry)
|
||||
│ ├── AGUI/ # AG-UI protocol samples
|
||||
│ ├── DeclarativeAgents/ # Declarative agent definitions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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;
|
||||
@@ -38,8 +39,27 @@ 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,6 +4,7 @@ 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;
|
||||
@@ -98,14 +99,7 @@ public class CopilotStudioAgent : AIAgent
|
||||
responseMessagesList.Add(message);
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
return CreateAgentResponse(responseMessagesList, this.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -132,24 +126,113 @@ 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);
|
||||
|
||||
// Enumerate the response messages
|
||||
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
|
||||
await foreach (AgentResponseUpdate update in CreateAgentResponseUpdatesAsync(responseMessages, this.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// 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,
|
||||
};
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,6 +19,10 @@
|
||||
<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,17 +1,14 @@
|
||||
// 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,16 +2,11 @@
|
||||
|
||||
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;
|
||||
@@ -51,7 +46,6 @@ 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>
|
||||
@@ -80,7 +74,6 @@ 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>
|
||||
@@ -222,6 +215,15 @@ 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();
|
||||
@@ -279,16 +281,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -343,13 +335,6 @@ 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,9 +3,6 @@
|
||||
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;
|
||||
|
||||
@@ -14,7 +11,6 @@ namespace Microsoft.Agents.AI;
|
||||
/// <summary>
|
||||
/// Represents configuration options for a <see cref="HarnessAgent"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class HarnessAgentOptions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -46,6 +42,7 @@ public sealed class HarnessAgentOptions
|
||||
/// <see langword="true"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public int? MaxContextWindowTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -62,6 +59,7 @@ 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>
|
||||
@@ -81,6 +79,7 @@ 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>
|
||||
@@ -92,6 +91,7 @@ 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,6 +162,7 @@ public sealed class HarnessAgentOptions
|
||||
/// as a single-shot agent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public IEnumerable<LoopEvaluator>? LoopEvaluators { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -171,6 +172,7 @@ 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>
|
||||
@@ -216,6 +218,19 @@ 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>
|
||||
@@ -234,6 +249,7 @@ 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>
|
||||
@@ -245,6 +261,7 @@ 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>
|
||||
@@ -254,6 +271,7 @@ 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>
|
||||
@@ -348,6 +366,7 @@ 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>
|
||||
@@ -357,76 +376,6 @@ 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,24 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>false</IsReleaseCandidate>
|
||||
<IsReleased>true</IsReleased>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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) };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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>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
|
||||
/// <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
|
||||
/// dimension. Hosts that serve more than one user from the same registered store must
|
||||
/// therefore compose a principal dimension into the lookup key, otherwise any caller
|
||||
/// who knows or guesses another caller's <c>conversationId</c> can resume
|
||||
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
|
||||
/// that other caller's persisted thread. The framework provides
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
|
||||
/// <c>conversationId</c> to include an isolation key resolved from a
|
||||
/// <c>sessionStoreId</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>conversationId</c> as opaque: do not parse it, do not impose length
|
||||
/// <c>sessionStoreId</c> as opaque: do not parse it, do not impose length
|
||||
/// or character-set constraints on it, and do not assume it round-trips to the value
|
||||
/// the caller originally supplied (decorators such as
|
||||
/// <see cref="IsolationKeyScopedAgentSessionStore"/> may rewrite it before forwarding).
|
||||
/// Be aware that any logging, telemetry, or audit sink that surfaces
|
||||
/// <c>conversationId</c> will also surface the isolation prefix when a
|
||||
/// <c>sessionStoreId</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="conversationId">The unique identifier for the conversation/session.</param>
|
||||
/// <param name="sessionStoreId">The id under which the session is stored.</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 conversationId,
|
||||
string sessionStoreId,
|
||||
AgentSession session,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -65,15 +65,44 @@ 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="conversationId">The unique identifier for the conversation/session to retrieve.</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 retrieval operation.
|
||||
/// The task result contains the serialized session state, or <see langword="null"/> if not found.
|
||||
/// 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.
|
||||
/// </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 conversationId,
|
||||
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,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Asks the <see cref="AgentSessionStore"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
|
||||
@@ -53,12 +53,16 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore
|
||||
protected AgentSessionStore InnerStore { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken);
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken);
|
||||
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);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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; }
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// 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,47 +63,54 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs.
|
||||
/// Escapes special characters in the isolation key to ensure unambiguous scoped session store IDs.
|
||||
/// </summary>
|
||||
/// <param name="key">The raw isolation key.</param>
|
||||
/// <returns>The escaped isolation key.</returns>
|
||||
/// <remarks>
|
||||
/// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:).
|
||||
/// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly.
|
||||
/// This ensures the scoped session store ID format {key}::{sessionStoreId} can be parsed correctly.
|
||||
/// </remarks>
|
||||
private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:");
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key.
|
||||
/// Constructs a scoped session store ID by prefixing the bare session store ID with the escaped isolation key.
|
||||
/// </summary>
|
||||
/// <param name="bareConversationId">The original conversation ID.</param>
|
||||
/// <param name="bareSessionStoreId">The original session store ID.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID
|
||||
/// The scoped session store ID in the format {escapedKey}::{sessionStoreId}, or the bare session store ID
|
||||
/// if no isolation key is available and non-strict mode is enabled.
|
||||
/// </returns>
|
||||
private async ValueTask<string> GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken)
|
||||
private async ValueTask<string> GetScopedSessionStoreIdAsync(string bareSessionStoreId, CancellationToken cancellationToken)
|
||||
{
|
||||
string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (key == null)
|
||||
{
|
||||
return bareConversationId;
|
||||
return bareSessionStoreId;
|
||||
}
|
||||
|
||||
return $"{EscapeIsolationKey(key)}::{bareConversationId}";
|
||||
return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false);
|
||||
string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Multi-user warning.</strong> This store keys threads by
|
||||
/// <c>(agent.Id, conversationId)</c> only — it has no principal/owner dimension. When
|
||||
/// the conversation identifier originates from the wire (for example, an AG-UI
|
||||
/// <c>(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>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 conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
var key = GetKey(sessionStoreId, agent.Id);
|
||||
this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(conversationId, agent.Id);
|
||||
var key = GetKey(sessionStoreId, agent.Id);
|
||||
JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null;
|
||||
|
||||
return sessionContent switch
|
||||
@@ -63,5 +63,12 @@ public sealed class InMemoryAgentSessionStore : AgentSessionStore
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
|
||||
/// <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}";
|
||||
}
|
||||
|
||||
@@ -12,14 +12,20 @@ namespace Microsoft.Agents.AI.Hosting;
|
||||
public sealed class NoopAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new ValueTask();
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return agent.CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,7 @@ 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."""
|
||||
@@ -280,6 +281,7 @@ 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:
|
||||
@@ -303,6 +305,10 @@ 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:
|
||||
@@ -324,6 +330,32 @@ 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.
|
||||
|
||||
@@ -357,7 +389,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 == "os" and node.attr not in self._allowed_os_attrs:
|
||||
if isinstance(node.value, ast.Name) and node.value.id in self._os_aliases 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,9 +21,10 @@ 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
|
||||
/// — 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).
|
||||
/// — 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).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class HeadTailBuffer
|
||||
@@ -37,6 +38,7 @@ internal sealed class HeadTailBuffer
|
||||
private readonly Queue<byte[]> _tail = new();
|
||||
private int _tailBytes;
|
||||
private long _totalBytes;
|
||||
private bool _headSealed;
|
||||
|
||||
public HeadTailBuffer(int cap)
|
||||
{
|
||||
@@ -63,19 +65,22 @@ internal sealed class HeadTailBuffer
|
||||
var n = rune.EncodeToUtf8(scratch);
|
||||
this._totalBytes += n;
|
||||
|
||||
if (this._head.Count + n <= this._headCap)
|
||||
if (!this._headSealed && this._head.Count + n <= this._headCap)
|
||||
{
|
||||
for (var i = 0; i < n; i++) { this._head.Add(scratch[i]); }
|
||||
continue;
|
||||
}
|
||||
|
||||
// Head is full — append to tail as a single rune-sized chunk.
|
||||
// Once a complete rune cannot fit in the head, seal it and keep all later runes in the tail.
|
||||
this._headSealed = true;
|
||||
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._tailBytes > this._tailCap && this._tail.Count > 0)
|
||||
while (this._totalBytes > this._cap &&
|
||||
this._tailBytes > this._tailCap &&
|
||||
this._tail.Count > 0)
|
||||
{
|
||||
var dropped = this._tail.Dequeue();
|
||||
this._tailBytes -= dropped.Length;
|
||||
|
||||
+47
-8
@@ -1,6 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -22,19 +24,56 @@ internal static class AgentProviderExtensions
|
||||
{
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
|
||||
autoSend |= isWorkflowConversation;
|
||||
|
||||
// Process the agent response updates.
|
||||
// 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.
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
string? generatedMessageId = null;
|
||||
string? generatedMessageResponseId = null;
|
||||
ChatRole? generatedMessageRole = null;
|
||||
await foreach (AgentResponseUpdate update in agentUpdates.ConfigureAwait(false))
|
||||
{
|
||||
await AssignConversationIdAsync(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).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;
|
||||
}
|
||||
|
||||
updates.Add(update);
|
||||
|
||||
|
||||
+12
-3
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
@@ -21,21 +22,29 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, activityText);
|
||||
string responseId = Guid.NewGuid().ToString("N");
|
||||
string messageId = Guid.NewGuid().ToString("N");
|
||||
ChatMessage message = new(ChatRole.Assistant, activityText) { MessageId = messageId };
|
||||
|
||||
// Emit an AgentResponseUpdateEvent so chat protocols (e.g. AsAIAgent) receive the
|
||||
// activity text as streaming chat content. This event is yielded by WorkflowSession
|
||||
// unconditionally, mirroring how AgentProviderExtensions surfaces autoSend agent
|
||||
// updates — without it, SendActivity output is dropped whenever the host runs with
|
||||
// includeWorkflowOutputsInResponse = false (the default).
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, activityText) { AuthorName = this.Id };
|
||||
AgentResponseUpdate update =
|
||||
new(ChatRole.Assistant, activityText)
|
||||
{
|
||||
AuthorName = this.Id,
|
||||
MessageId = messageId,
|
||||
ResponseId = responseId,
|
||||
};
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, which
|
||||
// is the gated summary surfaced only when includeWorkflowOutputsInResponse = true.
|
||||
AgentResponse response = new([message]);
|
||||
AgentResponse response = new([message]) { ResponseId = responseId };
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
@@ -58,4 +59,29 @@ public sealed class CheckpointManager : ICheckpointManager
|
||||
|
||||
ValueTask<IEnumerable<CheckpointInfo>> ICheckpointManager.RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent)
|
||||
=> this._impl.RetrieveIndexAsync(sessionId, withParent);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the most recently committed checkpoint for the specified session, or <see langword="null"/>
|
||||
/// when the session has no checkpoints.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session identifier whose latest checkpoint should be retrieved.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>
|
||||
/// The latest <see cref="CheckpointInfo"/> for <paramref name="sessionId"/>, or <see langword="null"/> when no
|
||||
/// checkpoint has been committed for that session.
|
||||
/// </returns>
|
||||
public async ValueTask<CheckpointInfo?> GetLatestCheckpointAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// ICheckpointStore.RetrieveIndexAsync is contractually required to return checkpoints in commit order
|
||||
// (oldest first, most recently committed last), so the last enumerated entry is the latest checkpoint.
|
||||
IEnumerable<CheckpointInfo> index = await this._impl.RetrieveIndexAsync(sessionId, withParent: null).ConfigureAwait(false);
|
||||
|
||||
CheckpointInfo? latest = null;
|
||||
foreach (CheckpointInfo info in index)
|
||||
{
|
||||
latest = info;
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -34,7 +34,19 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
private FileStream? _indexFile;
|
||||
|
||||
internal DirectoryInfo Directory { get; }
|
||||
|
||||
// O(1) membership set used to allocate unique checkpoint ids (GetUnusedCheckpointInfo) and to guard
|
||||
// RetrieveCheckpointAsync. It intentionally coexists with OrderedCheckpointIndex below: this set answers
|
||||
// "does this checkpoint exist?" in O(1), while the list preserves commit order. A single HashSet cannot do
|
||||
// both because HashSet enumeration order is not a contract.
|
||||
internal HashSet<CheckpointInfo> CheckpointIndex { get; }
|
||||
|
||||
// Insertion-ordered mirror of CheckpointIndex. HashSet enumeration order is not a contract (it can diverge
|
||||
// from insertion order once a slot is freed by a rollback and reused), so RetrieveIndexAsync enumerates this
|
||||
// list to return checkpoints in commit order, which callers such as CheckpointManager.GetLatestCheckpointAsync
|
||||
// rely on to identify the head checkpoint.
|
||||
private List<CheckpointInfo> OrderedCheckpointIndex { get; } = [];
|
||||
|
||||
private Dictionary<CheckpointInfo, string?> CheckpointParents { get; } = [];
|
||||
private HashSet<CheckpointInfo> CheckpointsWithKnownParent { get; } = [];
|
||||
|
||||
@@ -80,7 +92,11 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
// We never actually use the file names from the index entries since they can be derived from the CheckpointInfo, but it is useful to
|
||||
// have the UrlEncoded file names in the index file for human readability
|
||||
this.CheckpointIndex.Add(entry.CheckpointInfo);
|
||||
if (this.CheckpointIndex.Add(entry.CheckpointInfo))
|
||||
{
|
||||
this.OrderedCheckpointIndex.Add(entry.CheckpointInfo);
|
||||
}
|
||||
|
||||
this.CheckpointParents[entry.CheckpointInfo] = entry.ParentCheckpointId;
|
||||
if (entry.HasParentMetadata)
|
||||
{
|
||||
@@ -129,6 +145,8 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
key = new(sessionId);
|
||||
} while (!this.CheckpointIndex.Add(key));
|
||||
|
||||
this.OrderedCheckpointIndex.Add(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -164,6 +182,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.CheckpointIndex.Remove(key);
|
||||
this.OrderedCheckpointIndex.Remove(key);
|
||||
this.CheckpointParents.Remove(key);
|
||||
this.CheckpointsWithKnownParent.Remove(key);
|
||||
|
||||
@@ -202,7 +221,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos
|
||||
{
|
||||
this.CheckDisposed();
|
||||
|
||||
return new(this.CheckpointIndex
|
||||
return new(this.OrderedCheckpointIndex
|
||||
.Where(checkpoint => checkpoint.SessionId == sessionId &&
|
||||
(withParent is null ||
|
||||
!this.CheckpointsWithKnownParent.Contains(checkpoint) ||
|
||||
|
||||
@@ -19,8 +19,14 @@ public interface ICheckpointStore<TStoreObject>
|
||||
/// <param name="withParent">An optional parent checkpoint to filter the results. If specified, only checkpoints with the given parent are
|
||||
/// returned; otherwise, all checkpoints for the session are included.</param>
|
||||
/// <returns>A value task representing the asynchronous operation. The result contains a collection of <see
|
||||
/// cref="CheckpointInfo"/> objects associated with the specified session. The collection is empty if no checkpoints are
|
||||
/// found.</returns>
|
||||
/// cref="CheckpointInfo"/> objects associated with the specified session, ordered by commit time from the oldest to the
|
||||
/// most recently committed. The collection is empty if no checkpoints are found.</returns>
|
||||
/// <remarks>
|
||||
/// Implementations must return checkpoints in the order they were committed, with the most recently committed checkpoint
|
||||
/// last. This ordering is a contract of the store: callers such as <see cref="CheckpointManager"/> rely on it to identify
|
||||
/// the latest checkpoint for a session, so a store that returns checkpoints unordered (or newest-first) will cause the
|
||||
/// wrong checkpoint to be resumed.
|
||||
/// </remarks>
|
||||
ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// The built-in English prompt templates the Magentic manager uses. These are exposed so callers can read them and
|
||||
/// base a <see cref="MagenticPromptOverrides"/> value on the default (for example, translating it or appending a
|
||||
/// language instruction) instead of writing a prompt from scratch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each template uses named single-brace placeholders (e.g. <c>{task}</c>) that the framework substitutes at render
|
||||
/// time; the available placeholders per prompt are documented on the corresponding <see cref="MagenticPromptOverrides"/>
|
||||
/// property. A progress-ledger override must keep the <c>{schema}</c> placeholder so the JSON schema can be injected.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class MagenticDefaultPrompts
|
||||
{
|
||||
/// <summary>The default template for gathering the initial fact sheet. Placeholders: <c>{task}</c>.</summary>
|
||||
public static readonly string TaskLedgerFactsPrompt = """
|
||||
Below I will present you a request.
|
||||
|
||||
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
|
||||
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
|
||||
a deep well to draw from.
|
||||
|
||||
Here is the request:
|
||||
|
||||
{task}
|
||||
|
||||
Here is the pre-survey:
|
||||
|
||||
1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that
|
||||
there are none.
|
||||
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
|
||||
In some cases, authoritative sources are mentioned in the request itself.
|
||||
3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation)
|
||||
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
|
||||
|
||||
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
|
||||
Your answer should use headings:
|
||||
|
||||
1. GIVEN OR VERIFIED FACTS
|
||||
2. FACTS TO LOOK UP
|
||||
3. FACTS TO DERIVE
|
||||
4. EDUCATED GUESSES
|
||||
|
||||
DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so.
|
||||
""";
|
||||
|
||||
/// <summary>The default template for updating the fact sheet on a replan. Placeholders: <c>{task}</c>, <c>{old_facts}</c>.</summary>
|
||||
public static readonly string TaskLedgerFactsUpdatePrompt = """
|
||||
As a reminder, we are working to solve the following task:
|
||||
|
||||
{task}
|
||||
|
||||
It is clear we are not making as much progress as we would like, but we may have learned something new.
|
||||
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
|
||||
|
||||
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
|
||||
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
|
||||
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
|
||||
one educated guess or hunch, and explain your reasoning.
|
||||
|
||||
Here is the old fact sheet:
|
||||
|
||||
{old_facts}
|
||||
""";
|
||||
|
||||
/// <summary>The default template for creating the initial plan. Placeholders: <c>{team}</c>.</summary>
|
||||
public static readonly string TaskLedgerPlanPrompt = """
|
||||
Fantastic. To address this request we have assembled the following team:
|
||||
|
||||
{team}
|
||||
|
||||
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
|
||||
original request. Remember, there is no requirement to involve all team members. A team member's particular expertise
|
||||
may not be needed for this task.
|
||||
""";
|
||||
|
||||
/// <summary>The default template for updating the plan on a replan. Placeholders: <c>{team}</c>.</summary>
|
||||
public static readonly string TaskLedgerPlanUpdatePrompt = """
|
||||
Please briefly explain what went wrong on this last run
|
||||
(the root cause of the failure), and then come up with a new plan that takes steps and includes hints to overcome prior
|
||||
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, expressed in
|
||||
bullet-point form, and consider the following team composition:
|
||||
|
||||
{team}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The default template for the full task ledger (the plan-event text combining facts and plan).
|
||||
/// Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{facts}</c>, <c>{plan}</c>.
|
||||
/// </summary>
|
||||
public static readonly string TaskLedgerFullPrompt = """
|
||||
We are working to address the following user request:
|
||||
|
||||
{task}
|
||||
|
||||
|
||||
To answer this request we have assembled the following team:
|
||||
|
||||
{team}
|
||||
|
||||
|
||||
Here is an initial fact sheet to consider:
|
||||
|
||||
{facts}
|
||||
|
||||
|
||||
Here is the plan to follow as best as possible:
|
||||
|
||||
{plan}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// The default progress-ledger template. Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{questions}</c>,
|
||||
/// <c>{schema}</c>. An override must keep <c>{schema}</c> so the JSON schema the response is parsed against can
|
||||
/// be injected.
|
||||
/// </summary>
|
||||
public static readonly string ProgressLedgerPrompt = """
|
||||
Recall we are working on the following request:
|
||||
|
||||
{task}
|
||||
|
||||
And we have assembled the following team:
|
||||
|
||||
{team}
|
||||
|
||||
To make progress on the request, please answer the following questions, including necessary reasoning:
|
||||
|
||||
{questions}
|
||||
|
||||
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
|
||||
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
|
||||
|
||||
{schema}
|
||||
""";
|
||||
|
||||
/// <summary>The default template for synthesizing the final answer. Placeholders: <c>{task}</c>.</summary>
|
||||
public static readonly string FinalAnswerPrompt = """
|
||||
We are working on the following task:
|
||||
{task}
|
||||
|
||||
We have completed the task.
|
||||
|
||||
The above messages contain the conversation that took place to complete the task.
|
||||
|
||||
Based on the information gathered, provide the final answer to the original request.
|
||||
The answer should be phrased as if you were speaking to the user.
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Optional overrides for the internal prompt templates the Magentic manager uses to plan, track progress, and
|
||||
/// synthesize the final answer. Any property left <see langword="null"/> keeps the built-in English template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Overrides are supplied to <see cref="MagenticWorkflowBuilder.WithPromptOverrides(MagenticPromptOverrides)"/>.
|
||||
/// Each template may contain named single-brace placeholders that the framework substitutes at render time
|
||||
/// (e.g. <c>{task}</c>). Unlike Python's <c>str.format</c>, literal braces (such as JSON in the progress-ledger
|
||||
/// prompt) do <b>not</b> need to be escaped - only the documented placeholders are replaced.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The available placeholders differ per prompt and are documented on each property. A placeholder that is not
|
||||
/// available for a given prompt is left untouched.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To base an override on a built-in template (for example, to translate it), read the corresponding member on
|
||||
/// <see cref="MagenticDefaultPrompts"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <c>MagenticWorkflowBuilder.WithResponseLanguage</c> is also set, its language directive is appended after the
|
||||
/// (possibly overridden) template body, so overrides and the language pin compose.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed record MagenticPromptOverrides
|
||||
{
|
||||
/// <summary>
|
||||
/// Overrides the prompt that gathers the initial fact sheet. Placeholders: <c>{task}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerFactsPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that creates the initial plan. Placeholders: <c>{team}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerPlanPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that renders the full task ledger (the plan-event text combining facts and plan).
|
||||
/// Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{facts}</c>, <c>{plan}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerFullPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that updates the fact sheet during a replan. Placeholders: <c>{task}</c>, <c>{old_facts}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerFactsUpdatePrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that updates the plan during a replan. Placeholders: <c>{team}</c>.
|
||||
/// </summary>
|
||||
public string? TaskLedgerPlanUpdatePrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the progress-ledger prompt. Placeholders: <c>{task}</c>, <c>{team}</c>, <c>{questions}</c>,
|
||||
/// <c>{schema}</c>. The <c>{schema}</c> placeholder is required - the framework injects the JSON schema the
|
||||
/// response is parsed against, so omitting it would break progress-ledger parsing and next-speaker routing.
|
||||
/// </summary>
|
||||
public string? ProgressLedgerPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the prompt that synthesizes the final answer. Placeholders: <c>{task}</c>.
|
||||
/// </summary>
|
||||
public string? FinalAnswerPrompt { get; init; }
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
|
||||
string,
|
||||
@@ -33,6 +35,8 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
private int? _maxRounds;
|
||||
private int? _maxResets;
|
||||
private bool _requirePlanSignoff = true;
|
||||
private string? _responseLanguage;
|
||||
private MagenticPromptOverrides? _promptOverrides;
|
||||
|
||||
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
|
||||
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
|
||||
@@ -82,13 +86,72 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the concrete language (e.g. "English", "Chinese") that the Magentic manager's internally generated
|
||||
/// messages - the task ledger, progress ledger, and final answer - must be written in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, the manager is instructed to write all natural-language content in this exact language. This is more
|
||||
/// reliable than relying on the model to infer and match the request language, which some models fail to do for the
|
||||
/// progress ledger's JSON free-text fields, causing those internal messages to appear in an unexpected language.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When left unset (the default), the built-in English prompt templates are used as-is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If a prompt is also overridden via <see cref="WithPromptOverrides(MagenticPromptOverrides)"/>, this language
|
||||
/// directive is appended after that override's body, so the two compose.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option is experimental and may change or be removed in a future release.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="responseLanguage">
|
||||
/// The language name to use for internally generated messages, or <see langword="null"/> to use the built-in
|
||||
/// English templates as-is.
|
||||
/// </param>
|
||||
/// <returns>This builder instance, for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public MagenticWorkflowBuilder WithResponseLanguage(string? responseLanguage = null)
|
||||
{
|
||||
this._responseLanguage = string.IsNullOrWhiteSpace(responseLanguage) ? null : responseLanguage!.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override any of the Magentic manager's internal prompt templates (task ledger, progress ledger, final answer).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Any property left <see langword="null"/> on <paramref name="promptOverrides"/> keeps the built-in English
|
||||
/// template. Templates use named single-brace placeholders (e.g. <c>{task}</c>) documented on
|
||||
/// <see cref="MagenticPromptOverrides"/>; the framework substitutes them at render time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A progress-ledger override must contain the <c>{schema}</c> placeholder (validated at <see cref="Build"/>) so
|
||||
/// the framework can inject the JSON schema the response is parsed against.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This option is experimental and may change or be removed in a future release.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="promptOverrides">The prompt overrides to apply, or <see langword="null"/> to clear any overrides.</param>
|
||||
/// <returns>This builder instance, for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public MagenticWorkflowBuilder WithPromptOverrides(MagenticPromptOverrides? promptOverrides = null)
|
||||
{
|
||||
this._promptOverrides = promptOverrides;
|
||||
return this;
|
||||
}
|
||||
|
||||
private WorkflowBuilder ReduceToWorkflowBuilder()
|
||||
{
|
||||
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
|
||||
// workflow in unexpected ways.
|
||||
List<AIAgent> team = [.. this._team];
|
||||
|
||||
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
|
||||
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff, this._responseLanguage, this._promptOverrides);
|
||||
WorkflowBuilder result = new(orchestrator);
|
||||
|
||||
AIAgentHostOptions options = new()
|
||||
@@ -131,6 +194,13 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
throw new InvalidOperationException("At least one participant must be added via AddParticipants() before building the workflow.");
|
||||
}
|
||||
|
||||
if (this._promptOverrides?.ProgressLedgerPrompt is { } progressLedgerPrompt && !progressLedgerPrompt.Contains("{schema}"))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A progress-ledger prompt override must contain the '{schema}' placeholder so the required JSON schema can be injected; " +
|
||||
"otherwise progress-ledger parsing and next-speaker routing would break.");
|
||||
}
|
||||
|
||||
return this.ReduceToWorkflowBuilder().Build();
|
||||
}
|
||||
|
||||
@@ -139,14 +209,14 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilde
|
||||
MaxResetCount: this._maxResets,
|
||||
MaxStallCount: this._maxStalls);
|
||||
|
||||
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff, string? responseLanguage, MagenticPromptOverrides? promptOverrides)
|
||||
{
|
||||
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
|
||||
return factory.BindExecutor(nameof(MagenticOrchestrator));
|
||||
|
||||
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
|
||||
{
|
||||
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
|
||||
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff, responseLanguage, promptOverrides));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,162 +4,132 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal sealed class MessageMerger
|
||||
{
|
||||
private sealed class MessageMergeState(string? messageId)
|
||||
{
|
||||
public string? MessageId { get; } = messageId;
|
||||
|
||||
public List<AgentResponseUpdate> Updates { get; } = [];
|
||||
}
|
||||
|
||||
private sealed class ResponseMergeState(string? responseId)
|
||||
{
|
||||
public string? ResponseId { get; } = responseId;
|
||||
private readonly Dictionary<string, MessageMergeState> _messageStates = [];
|
||||
private readonly List<MessageMergeState> _messageStatesInOrder = [];
|
||||
private MessageMergeState? _lastObservedState;
|
||||
|
||||
public Dictionary<string, List<AgentResponseUpdate>> UpdatesByMessageId { get; } = [];
|
||||
public List<AgentResponseUpdate> DanglingUpdates { get; } = [];
|
||||
public string? ResponseId { get; } = responseId;
|
||||
|
||||
public void AddUpdate(AgentResponseUpdate update)
|
||||
{
|
||||
if (update.MessageId is null)
|
||||
MessageMergeState state = this.GetOrCreateMessageState(update.MessageId);
|
||||
state.Updates.Add(update);
|
||||
this._lastObservedState = state;
|
||||
}
|
||||
|
||||
private MessageMergeState GetOrCreateMessageState(string? messageId)
|
||||
{
|
||||
if (messageId is null)
|
||||
{
|
||||
this.DanglingUpdates.Add(update);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!this.UpdatesByMessageId.TryGetValue(update.MessageId, out List<AgentResponseUpdate>? updates))
|
||||
if (this._lastObservedState is { MessageId: null })
|
||||
{
|
||||
this.UpdatesByMessageId[update.MessageId] = updates = [];
|
||||
return this._lastObservedState;
|
||||
}
|
||||
|
||||
updates.Add(update);
|
||||
MessageMergeState state = new(null);
|
||||
this._messageStatesInOrder.Add(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!this._messageStates.TryGetValue(messageId, out MessageMergeState? existingState))
|
||||
{
|
||||
existingState = new(messageId);
|
||||
this._messageStates[messageId] = existingState;
|
||||
this._messageStatesInOrder.Add(existingState);
|
||||
}
|
||||
|
||||
return existingState;
|
||||
}
|
||||
|
||||
public AgentResponse ComputeMerged(string messageId)
|
||||
public List<AgentResponse> ComputeMerged()
|
||||
{
|
||||
if (this.UpdatesByMessageId.TryGetValue(Throw.IfNull(messageId), out List<AgentResponseUpdate>? updates))
|
||||
// Message buckets keep their first-seen order. Grouping updates into messages is delegated
|
||||
// to M.E.AI (ToAgentResponse), which coalesces contiguous updates by message id exactly like
|
||||
// a directly-invoked agent. Folding an id-less segment (e.g. a streamed reasoning summary)
|
||||
// into the following id'd message of the same role is handled once, at the flattened-message
|
||||
// level in MessageMerger.ComputeMerged, so it works both within a single response bucket and
|
||||
// across buckets (see https://github.com/microsoft/agent-framework/issues/6329).
|
||||
List<MessageMergeState> ordered = this._messageStatesInOrder;
|
||||
List<AgentResponse> responses = new(ordered.Count);
|
||||
|
||||
foreach (MessageMergeState current in ordered)
|
||||
{
|
||||
return updates.ToAgentResponse();
|
||||
responses.Add(current.Updates.ToAgentResponse());
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'.");
|
||||
}
|
||||
|
||||
public AgentResponse ComputeDangling()
|
||||
{
|
||||
if (this.DanglingUpdates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No dangling updates to compute a response from.");
|
||||
}
|
||||
|
||||
return this.DanglingUpdates.ToAgentResponse();
|
||||
return responses;
|
||||
}
|
||||
|
||||
public List<ChatMessage> ComputeFlattened()
|
||||
{
|
||||
List<ChatMessage> result = this.UpdatesByMessageId.Keys.SelectMany(AggregateUpdatesToMessage).ToList();
|
||||
if (this.DanglingUpdates.Count > 0)
|
||||
{
|
||||
result.AddRange(this.ComputeDangling().Messages);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
IList<ChatMessage> AggregateUpdatesToMessage(string messageId)
|
||||
{
|
||||
List<AgentResponseUpdate> updates = this.UpdatesByMessageId[messageId];
|
||||
if (updates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'.");
|
||||
}
|
||||
|
||||
return updates.Select(oldUpdate => oldUpdate.AsChatResponseUpdate()).ToChatResponse().Messages;
|
||||
}
|
||||
}
|
||||
=> this.ComputeMerged().SelectMany(response => response.Messages).ToList();
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, ResponseMergeState> _mergeStates = [];
|
||||
private readonly List<string> _responseIdsInOrder = [];
|
||||
private readonly ResponseMergeState _danglingState = new(null);
|
||||
|
||||
public void AddUpdate(AgentResponseUpdate update)
|
||||
{
|
||||
if (update.ResponseId is null)
|
||||
{
|
||||
this._danglingState.DanglingUpdates.Add(update);
|
||||
this._danglingState.AddUpdate(update);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!this._mergeStates.TryGetValue(update.ResponseId, out ResponseMergeState? state))
|
||||
{
|
||||
this._mergeStates[update.ResponseId] = state = new ResponseMergeState(update.ResponseId);
|
||||
this._responseIdsInOrder.Add(update.ResponseId);
|
||||
}
|
||||
|
||||
state.AddUpdate(update);
|
||||
}
|
||||
}
|
||||
|
||||
private int CompareByDateTimeOffset(AgentResponse left, AgentResponse right)
|
||||
{
|
||||
const int LESS = -1, EQ = 0, GREATER = 1;
|
||||
|
||||
if (left.CreatedAt == right.CreatedAt)
|
||||
{
|
||||
return EQ;
|
||||
}
|
||||
|
||||
if (!left.CreatedAt.HasValue)
|
||||
{
|
||||
return GREATER;
|
||||
}
|
||||
|
||||
if (!right.CreatedAt.HasValue)
|
||||
{
|
||||
return LESS;
|
||||
}
|
||||
|
||||
return left.CreatedAt.Value.CompareTo(right.CreatedAt.Value);
|
||||
}
|
||||
|
||||
public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgentId = null, string? primaryAgentName = null)
|
||||
{
|
||||
List<ChatMessage> messages = [];
|
||||
Dictionary<string, AgentResponse> responses = [];
|
||||
List<AgentResponse> responses = [];
|
||||
HashSet<string> agentIds = [];
|
||||
HashSet<ChatFinishReason> finishReasons = [];
|
||||
|
||||
foreach (string responseId in this._mergeStates.Keys)
|
||||
foreach (string responseId in this._responseIdsInOrder)
|
||||
{
|
||||
ResponseMergeState mergeState = this._mergeStates[responseId];
|
||||
|
||||
List<AgentResponse> responseList = mergeState.UpdatesByMessageId.Keys.Select(mergeState.ComputeMerged).ToList();
|
||||
if (mergeState.DanglingUpdates.Count > 0)
|
||||
{
|
||||
responseList.Add(mergeState.ComputeDangling());
|
||||
}
|
||||
|
||||
responseList.Sort(this.CompareByDateTimeOffset);
|
||||
responses[responseId] = responseList.Aggregate(MergeResponses);
|
||||
messages.AddRange(GetMessagesWithCreatedAt(responses[responseId]));
|
||||
List<AgentResponse> responseList = mergeState.ComputeMerged();
|
||||
AgentResponse response = responseList.Aggregate(MergeResponses);
|
||||
responses.Add(response);
|
||||
messages.AddRange(GetMessagesWithCreatedAt(response));
|
||||
}
|
||||
|
||||
UsageDetails? usage = null;
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
HashSet<DateTimeOffset> createdTimes = [];
|
||||
|
||||
foreach (AgentResponse response in responses.Values)
|
||||
foreach (AgentResponse response in responses)
|
||||
{
|
||||
if (response.AgentId is not null)
|
||||
{
|
||||
agentIds.Add(response.AgentId);
|
||||
}
|
||||
|
||||
if (response.CreatedAt.HasValue)
|
||||
{
|
||||
createdTimes.Add(response.CreatedAt.Value);
|
||||
_ = agentIds.Add(response.AgentId);
|
||||
}
|
||||
|
||||
if (response.FinishReason.HasValue)
|
||||
{
|
||||
finishReasons.Add(response.FinishReason.Value);
|
||||
_ = finishReasons.Add(response.FinishReason.Value);
|
||||
}
|
||||
|
||||
usage = MergeUsage(usage, response.Usage);
|
||||
@@ -168,6 +138,36 @@ internal sealed class MessageMerger
|
||||
|
||||
messages.AddRange(this._danglingState.ComputeFlattened());
|
||||
|
||||
// Fold an id-less message that is immediately followed by an id'd message of the same role
|
||||
// into that message. A streamed reasoning summary often arrives without a message id and, when
|
||||
// an agent is hosted inside a workflow, can land in a different response bucket than the answer
|
||||
// text that follows it. The per-response fold cannot merge across buckets, so we also fold here
|
||||
// at the flattened-message level to keep the reasoning and the answer in a single assistant
|
||||
// message (see https://github.com/microsoft/agent-framework/issues/6329).
|
||||
// We iterate backward so that a run of consecutive id-less messages preceding an id'd message
|
||||
// all cascade into that message: once folded, the merged message adopts next.MessageId, so a
|
||||
// forward pass would never re-examine the preceding id-less entry.
|
||||
for (int i = messages.Count - 1; i > 0; i--)
|
||||
{
|
||||
ChatMessage current = messages[i - 1];
|
||||
ChatMessage next = messages[i];
|
||||
|
||||
if (current.MessageId is null && next.MessageId is not null && current.Role == next.Role)
|
||||
{
|
||||
messages[i] = new ChatMessage
|
||||
{
|
||||
Role = next.Role,
|
||||
AuthorName = next.AuthorName ?? current.AuthorName,
|
||||
Contents = [.. current.Contents, .. next.Contents],
|
||||
MessageId = next.MessageId,
|
||||
CreatedAt = current.CreatedAt ?? next.CreatedAt,
|
||||
RawRepresentation = next.RawRepresentation,
|
||||
AdditionalProperties = next.AdditionalProperties,
|
||||
};
|
||||
messages.RemoveAt(i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any empty text contents or messages that are now empty.
|
||||
foreach (var m in messages)
|
||||
{
|
||||
@@ -180,7 +180,8 @@ internal sealed class MessageMerger
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.RemoveAll(m => m.Contents.Count == 0);
|
||||
|
||||
_ = messages.RemoveAll(m => m.Contents.Count == 0);
|
||||
|
||||
return new AgentResponse(messages)
|
||||
{
|
||||
@@ -242,8 +243,9 @@ internal sealed class MessageMerger
|
||||
AuthorName = message.AuthorName,
|
||||
Contents = message.Contents,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = createdAt,
|
||||
RawRepresentation = message.RawRepresentation
|
||||
CreatedAt = message.CreatedAt ?? createdAt,
|
||||
RawRepresentation = message.RawRepresentation,
|
||||
AdditionalProperties = message.AdditionalProperties
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user