Compare commits
38 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 | |||
| 85fde62a76 | |||
| 85eb53d412 | |||
| 2d7c8da6b0 | |||
| 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,
|
||||
};
|
||||
@@ -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,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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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`.
|
||||
@@ -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" />
|
||||
@@ -314,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/" />
|
||||
@@ -640,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" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.14.0</VersionPrefix>
|
||||
<VersionPrefix>1.15.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260721</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.14.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.");
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -229,8 +229,46 @@ internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
string? generatedMessageId = null;
|
||||
string? generatedMessageResponseId = null;
|
||||
ChatRole? generatedMessageRole = null;
|
||||
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
|
||||
{
|
||||
// Contentless updates may carry only metadata and must not become empty messages.
|
||||
if (string.IsNullOrWhiteSpace(update.MessageId))
|
||||
{
|
||||
bool hasContent =
|
||||
update.Contents.Any(
|
||||
content => content is not TextContent textContent || !string.IsNullOrEmpty(textContent.Text));
|
||||
if (hasContent)
|
||||
{
|
||||
if (generatedMessageId is null
|
||||
|| (generatedMessageResponseId is not null
|
||||
&& update.ResponseId is not null
|
||||
&& !string.Equals(generatedMessageResponseId, update.ResponseId, StringComparison.Ordinal))
|
||||
|| (generatedMessageRole is not null
|
||||
&& update.Role is not null
|
||||
&& generatedMessageRole != update.Role))
|
||||
{
|
||||
generatedMessageId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
generatedMessageResponseId = update.ResponseId ?? generatedMessageResponseId;
|
||||
generatedMessageRole = update.Role ?? generatedMessageRole;
|
||||
update.MessageId = generatedMessageId;
|
||||
if (update.RawRepresentation is ChatResponseUpdate rawUpdate)
|
||||
{
|
||||
rawUpdate.MessageId = generatedMessageId;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
generatedMessageId = null;
|
||||
generatedMessageResponseId = null;
|
||||
generatedMessageRole = null;
|
||||
}
|
||||
|
||||
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
|
||||
collector.ProcessAgentResponseUpdate(update);
|
||||
updates.Add(update);
|
||||
|
||||
+11
-3
@@ -77,7 +77,9 @@ public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger pr
|
||||
/// <param name="team"></param>
|
||||
/// <param name="limits"></param>
|
||||
/// <param name="requirePlanSignoff"></param>
|
||||
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
/// <param name="responseLanguage"></param>
|
||||
/// <param name="promptOverrides"></param>
|
||||
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff, string? responseLanguage = null, MagenticPromptOverrides? promptOverrides = null)
|
||||
: ChatProtocolExecutor(nameof(MagenticOrchestrator), s_options, declareCrossRunShareable: false)
|
||||
{
|
||||
private readonly MagenticManager _manager = new(managerAgent);
|
||||
@@ -191,7 +193,7 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
if (this._taskContext == null)
|
||||
{
|
||||
// First Turn: Initialize the task context and create the initial plan
|
||||
this._taskContext = new(messages, team, limits, emitEvents, []);
|
||||
this._taskContext = new(messages, team, limits, emitEvents, []) { ResponseLanguage = responseLanguage, PromptOverrides = promptOverrides };
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
@@ -384,7 +386,13 @@ internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, Ta
|
||||
|
||||
if (state != null)
|
||||
{
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, []);
|
||||
// ResponseLanguage and PromptOverrides are build-time configuration supplied by the builder, so they
|
||||
// are re-applied here rather than restored from the checkpoint state.
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, [])
|
||||
{
|
||||
ResponseLanguage = responseLanguage,
|
||||
PromptOverrides = promptOverrides,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,21 @@ internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgen
|
||||
|
||||
public List<ChatMessage> ChatHistory { get; internal set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional concrete language (e.g. "English", "Chinese") that the manager's internally generated messages
|
||||
/// must be written in, configured via <c>MagenticWorkflowBuilder.WithResponseLanguage</c>. When
|
||||
/// <see langword="null"/> the built-in English prompts are used as-is. This is build-time configuration
|
||||
/// (re-applied from the builder after a checkpoint restore), not runtime state.
|
||||
/// </summary>
|
||||
public string? ResponseLanguage { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional user-supplied overrides for the manager's internal prompt templates, configured via
|
||||
/// <c>MagenticWorkflowBuilder.WithPromptOverrides</c>. When <see langword="null"/> the built-in templates
|
||||
/// are used. This is build-time configuration (re-applied from the builder after a checkpoint restore).
|
||||
/// </summary>
|
||||
public MagenticPromptOverrides? PromptOverrides { get; internal set; }
|
||||
|
||||
public TaskLedger? TaskLedger { get; internal set; }
|
||||
|
||||
public TaskLimits TaskLimits => limits;
|
||||
|
||||
@@ -1,151 +1,118 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static class PromptTemplateExtensions
|
||||
{
|
||||
// Matches a single-brace placeholder token, e.g. {task} or {old_facts}. Only {word} sequences are treated as
|
||||
// placeholders, so literal braces in a prompt (such as JSON in an override) are left untouched.
|
||||
private static readonly Regex s_placeholderPattern = new(@"\{(\w+)\}");
|
||||
|
||||
// The built-in English prompt templates live on the public MagenticDefaultPrompts class so callers can read and
|
||||
// base overrides on them. Named single-brace placeholders (e.g. {task}) are substituted at render time.
|
||||
private static string Substitute(string template, params (string Token, string Value)[] values) =>
|
||||
// Single-pass replacement over the template: substituted values are never re-scanned for further
|
||||
// placeholders, so content that happens to contain "{token}" text (e.g. in the task) is not corrupted.
|
||||
s_placeholderPattern.Replace(template, match =>
|
||||
{
|
||||
foreach ((string token, string value) in values)
|
||||
{
|
||||
if (string.Equals(token, match.Groups[1].Value, StringComparison.Ordinal))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Not one of the placeholders available for this prompt - leave the original text untouched.
|
||||
return match.Value;
|
||||
});
|
||||
|
||||
// When a concrete response language is configured via WithResponseLanguage, a directive pinning that language is
|
||||
// appended AFTER the (possibly overridden) prompt body. A concrete language name is followed far more reliably than
|
||||
// a relative "match the request" instruction, especially for the progress ledger's JSON free-text fields (#6987).
|
||||
private static string AppendLanguageDirective(string body, MagenticTaskContext taskContext) =>
|
||||
taskContext.ResponseLanguage is { Length: > 0 } language
|
||||
? $"{body}\n\n{GeneralLanguageDirective(language)}"
|
||||
: body;
|
||||
|
||||
private static string AppendProgressLedgerLanguageDirective(string body, MagenticTaskContext taskContext) =>
|
||||
taskContext.ResponseLanguage is { Length: > 0 } language
|
||||
? $"{body}\n\n{ProgressLedgerLanguageDirective(language)}"
|
||||
: body;
|
||||
|
||||
private static string GeneralLanguageDirective(string language) =>
|
||||
$"Write your entire response in {language}, including any section headings or labels. Do not use any other language.";
|
||||
|
||||
private static string ProgressLedgerLanguageDirective(string language) =>
|
||||
$"When filling in the JSON, write every \"reason\" value and the \"instruction_or_question\" answer in {language}. " +
|
||||
"Do not translate the JSON keys - they must remain exactly as shown above. The \"next_speaker\" answer must " +
|
||||
"remain exactly one of the provided team member names and must not be translated.";
|
||||
|
||||
public static string ToTaskLedgerFactsPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Below I will present you a request.
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerFactsPrompt ?? MagenticDefaultPrompts.TaskLedgerFactsPrompt,
|
||||
("task", taskContext.Task));
|
||||
|
||||
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:
|
||||
|
||||
{taskContext.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.
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerFactsUpdatePrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
As a reminder, we are working to solve the following task:
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerFactsUpdatePrompt ?? MagenticDefaultPrompts.TaskLedgerFactsUpdatePrompt,
|
||||
("task", taskContext.Task),
|
||||
("old_facts", taskContext.TaskLedger?.CurrentFacts.Text ?? string.Empty));
|
||||
|
||||
{taskContext.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:
|
||||
|
||||
{taskContext.TaskLedger?.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerPlanPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Fantastic. To address this request we have assembled the following team:
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerPlanPrompt ?? MagenticDefaultPrompts.TaskLedgerPlanPrompt,
|
||||
("team", taskContext.TeamDescription));
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
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.
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerPlanUpdatePrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
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:
|
||||
string body = Substitute(taskContext.PromptOverrides?.TaskLedgerPlanUpdatePrompt ?? MagenticDefaultPrompts.TaskLedgerPlanUpdatePrompt,
|
||||
("team", taskContext.TeamDescription));
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerFullPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
We are working to address the following user request:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
|
||||
To answer this request we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
|
||||
Here is an initial fact sheet to consider:
|
||||
|
||||
{taskContext.TaskLedger!.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
|
||||
|
||||
|
||||
Here is the plan to follow as best as possible:
|
||||
|
||||
{taskContext.TaskLedger!.CurrentPlan}
|
||||
""";
|
||||
// Assembly-only prompt (emitted as the plan-event text and used as context); no language directive is appended
|
||||
// because nothing is generated from it - its facts/plan are already localized by their own generation prompts.
|
||||
return Substitute(taskContext.PromptOverrides?.TaskLedgerFullPrompt ?? MagenticDefaultPrompts.TaskLedgerFullPrompt,
|
||||
("task", taskContext.Task),
|
||||
("team", taskContext.TeamDescription),
|
||||
("facts", taskContext.TaskLedger!.CurrentFacts.Text),
|
||||
("plan", taskContext.TaskLedger!.CurrentPlan.Text));
|
||||
}
|
||||
|
||||
public static string ToProgressLedgerPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
(string questions, string schema) = taskContext.ProgressLedger.FormatQuestions();
|
||||
|
||||
return $"""
|
||||
Recall we are working on the following request:
|
||||
string body = Substitute(taskContext.PromptOverrides?.ProgressLedgerPrompt ?? MagenticDefaultPrompts.ProgressLedgerPrompt,
|
||||
("task", taskContext.Task),
|
||||
("team", taskContext.TeamDescription),
|
||||
("questions", questions),
|
||||
("schema", schema));
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
And we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
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}
|
||||
""";
|
||||
return AppendProgressLedgerLanguageDirective(body, taskContext);
|
||||
}
|
||||
|
||||
public static string ToFinalAnswerPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
We are working on the following task:
|
||||
{taskContext.Task}
|
||||
string body = Substitute(taskContext.PromptOverrides?.FinalAnswerPrompt ?? MagenticDefaultPrompts.FinalAnswerPrompt,
|
||||
("task", taskContext.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.
|
||||
""";
|
||||
return AppendLanguageDirective(body, taskContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,24 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
=> this.WatchStreamAsync(blockOnPendingRequest: true, cancellationToken);
|
||||
|
||||
internal IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur, with control over how the stream behaves when the
|
||||
/// workflow halts awaiting an external response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <paramref name="blockOnPendingRequest"/> is <see langword="true"/> (the default behavior of
|
||||
/// <see cref="WatchStreamAsync(CancellationToken)"/>), the stream pauses at a pending-request halt and resumes
|
||||
/// once a response is supplied via <see cref="SendResponseAsync"/>. When it is <see langword="false"/>, the
|
||||
/// stream instead ends at that halt, returning control to the caller — matching the non-streaming
|
||||
/// <see cref="Run"/> behavior, which is useful for hosts that drive their own turn loop.
|
||||
/// </remarks>
|
||||
/// <param name="blockOnPendingRequest"><see langword="true"/> to block at a pending-request halt awaiting a
|
||||
/// response; <see langword="false"/> to end the stream at that halt.</param>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the streaming
|
||||
/// operation. If cancellation is requested, the stream will end and no further events will be yielded, but this
|
||||
/// will not cancel the workflow execution.</param>
|
||||
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects representing significant workflow state changes.</returns>
|
||||
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||
bool blockOnPendingRequest,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> this._runHandle.TakeEventStreamAsync(blockOnPendingRequest, cancellationToken);
|
||||
|
||||
@@ -455,6 +455,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
#pragma warning restore CA2007
|
||||
|
||||
ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
|
||||
HashSet<(string ExecutorId, string MessageId)> streamedMessageIds = [];
|
||||
|
||||
// Send a TurnToken to the start executor unless the only activity is an external
|
||||
// response directed at the start executor itself (which self-emits a TurnToken via
|
||||
@@ -485,6 +486,11 @@ internal sealed class WorkflowSession : AgentSession
|
||||
switch (evt)
|
||||
{
|
||||
case AgentResponseUpdateEvent agentUpdate:
|
||||
string? messageId = agentUpdate.Update.MessageId;
|
||||
if (!string.IsNullOrWhiteSpace(messageId))
|
||||
{
|
||||
streamedMessageIds.Add((agentUpdate.ExecutorId, messageId));
|
||||
}
|
||||
yield return agentUpdate.Update;
|
||||
break;
|
||||
|
||||
@@ -559,10 +565,29 @@ internal sealed class WorkflowSession : AgentSession
|
||||
// _includeWorkflowOutputInResponse flag is set. Reason being: The user specifies
|
||||
// exclusion of an event by enabling filtering and then _not_ marking an Executor
|
||||
// as an output executor.
|
||||
bool emittedMessage = false;
|
||||
bool suppressedStreamedMessage = false;
|
||||
foreach (ChatMessage message in agentResponse.Response.Messages)
|
||||
{
|
||||
string? completedMessageId = message.MessageId;
|
||||
bool messageWasStreamed =
|
||||
!string.IsNullOrWhiteSpace(completedMessageId)
|
||||
&& streamedMessageIds.Contains((agentResponse.ExecutorId, completedMessageId));
|
||||
if (messageWasStreamed)
|
||||
{
|
||||
suppressedStreamedMessage = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
emittedMessage = true;
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, message);
|
||||
}
|
||||
if (!emittedMessage && suppressedStreamedMessage)
|
||||
{
|
||||
// Preserve the completion event for observability after its correlated
|
||||
// streamed content has already been forwarded.
|
||||
yield return CreateObservabilityUpdate(evt);
|
||||
}
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent output:
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
|
||||
<NoWarn>$(NoWarn);OPENAI001;</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live integration tests for the app-owned routing helper surface (<see cref="OpenAIResponses"/> plus
|
||||
/// <see cref="AgentSessionStore"/>) exercised against a real OpenAI model. These confirm the crucial
|
||||
/// consumption paths — request conversion, an agent run, response rendering, and multi-turn session
|
||||
/// continuity — behave correctly end to end with a real chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skipped unless the OpenAI configuration is present (<c>OPENAI_API_KEY</c>), so runs without secrets stay
|
||||
/// green. The in-process, in-memory (no live model) coverage lives in the
|
||||
/// <c>Microsoft.Agents.AI.Hosting.OpenAI.UnitTests</c> project (<c>OpenAIResponsesHostingTests</c>).
|
||||
/// </remarks>
|
||||
public sealed class OpenAIResponsesHostingLiveTests
|
||||
{
|
||||
private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.OpenAIApiKey);
|
||||
private static string ModelName => Environment.GetEnvironmentVariable(TestSettings.OpenAIChatModelName) ?? "gpt-4o-mini";
|
||||
|
||||
[Fact]
|
||||
public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync()
|
||||
{
|
||||
// Arrange
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test.");
|
||||
AIAgent agent = CreateAgent();
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
|
||||
JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }""");
|
||||
|
||||
// Act
|
||||
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId);
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options);
|
||||
JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(responseId, payload.GetProperty("id").GetString());
|
||||
Assert.Equal("response", payload.GetProperty("object").GetString());
|
||||
Assert.Contains("output", payload.EnumerateObject().Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "OPENAI_API_KEY is not configured; skipping live hosting test.");
|
||||
AIAgent agent = CreateAgent();
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
|
||||
|
||||
// Act: first turn establishes context, second turn continues from the first response id.
|
||||
string firstResponseId = await RunTurnAsync(agent, sessionStore, """{ "input": "Remember the number 7." }""");
|
||||
JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }""");
|
||||
OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody);
|
||||
string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!;
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId);
|
||||
AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options);
|
||||
|
||||
// Assert: continuation succeeded and the model produced a textual answer.
|
||||
Assert.Equal(secondSessionStoreId, firstResponseId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(secondResult.Text));
|
||||
}
|
||||
|
||||
private static async Task<string> RunTurnAsync(AIAgent agent, AgentSessionStore sessionStore, string bodyJson)
|
||||
{
|
||||
JsonElement body = ParseBody(bodyJson);
|
||||
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId);
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
_ = await agent.RunAsync(run.Messages, session, run.Options);
|
||||
await sessionStore.SaveSessionAsync(agent, responseId, session);
|
||||
return responseId;
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateAgent() =>
|
||||
new(
|
||||
new OpenAIClient(ApiKey).GetChatClient(ModelName).AsIChatClient(),
|
||||
instructions: "You are a concise assistant.",
|
||||
name: "assistant");
|
||||
|
||||
private static JsonElement ParseBody(string json)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, in-memory hosting tests for <see cref="OpenAIResponses"/>: a request travels through an
|
||||
/// app-owned ASP.NET Core route (hosted in-memory via <see cref="TestServer"/>) that wires
|
||||
/// <see cref="OpenAIResponses"/> plus an <see cref="AgentSessionStore"/> / <see cref="HostedWorkflowState"/>,
|
||||
/// exactly like the <c>local_responses</c> / <c>local_responses_workflow</c> samples. These run fully
|
||||
/// in-process against a deterministic mock chat client — there is no external server process and no live
|
||||
/// model. Live-model coverage lives in the separate <c>Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests</c>
|
||||
/// project.
|
||||
/// </summary>
|
||||
public sealed class OpenAIResponsesHostingTests : IAsyncDisposable
|
||||
{
|
||||
private static readonly int[] s_independentBranchUserCounts = [1, 2, 2, 3, 3];
|
||||
private static readonly int[] s_conversationAdvanceUserCounts = [1, 2];
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _client;
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_NonStreaming_ReturnsResponsesShapedJsonAsync()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient client = await this.StartAgentHostAsync(new TestHelpers.ConversationMemoryMockChatClient("Hello from the agent"));
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
JsonContent("""{ "input": "Hi there" }"""));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
JsonElement root = doc.RootElement;
|
||||
Assert.StartsWith("resp_", root.GetProperty("id").GetString());
|
||||
Assert.Equal("response", root.GetProperty("object").GetString());
|
||||
Assert.Contains("Hello from the agent", root.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_Streaming_ReturnsServerSentEventsAsync()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient client = await this.StartAgentHostAsync(new TestHelpers.ConversationMemoryMockChatClient("Streamed answer"));
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
JsonContent("""{ "input": "Hi there", "stream": true }"""));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("event: response.created", body, StringComparison.Ordinal);
|
||||
Assert.Contains("event: response.completed", body, StringComparison.Ordinal);
|
||||
Assert.Contains("Streamed answer", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_MultiTurn_ReusesSessionAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange: a recording mock so we can prove the second turn saw the first turn's history.
|
||||
var recorder = new TestHelpers.ConversationMemoryMockChatClient("ok");
|
||||
HttpClient client = await this.StartAgentHostAsync(recorder);
|
||||
|
||||
// Act: first turn, then continue using the returned response id as previous_response_id.
|
||||
using JsonDocument first = JsonDocument.Parse(await (await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative), JsonContent("""{ "input": "first turn" }"""))).Content.ReadAsStringAsync());
|
||||
string responseId = first.RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
HttpResponseMessage secondResponse = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
JsonContent($$"""{ "input": "second turn", "previous_response_id": "{{responseId}}" }"""));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
Assert.Equal(2, recorder.CallHistory.Count);
|
||||
|
||||
// The second call must include the first turn's user message (continuity through the session store),
|
||||
// whereas the first call must not contain the second turn's text.
|
||||
string firstCallText = string.Join("\n", recorder.CallHistory[0].Select(m => m.Text));
|
||||
string secondCallText = string.Join("\n", recorder.CallHistory[1].Select(m => m.Text));
|
||||
Assert.Contains("first turn", secondCallText, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("second turn", firstCallText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_PreviousResponseId_SupportsIndependentBranchesAsync()
|
||||
{
|
||||
// Arrange: a recording mock so we can count how much history each turn saw.
|
||||
var recorder = new TestHelpers.ConversationMemoryMockChatClient("ok");
|
||||
HttpClient client = await this.StartAgentHostAsync(recorder);
|
||||
|
||||
// Act: a root turn, then two branches from the SAME response id, then a continuation of each branch.
|
||||
string rootId = await PostAndGetResponseIdAsync(client, """{ "input": "root" }""");
|
||||
string branchOneId = await PostAndGetResponseIdAsync(client, $$"""{ "input": "branch one", "previous_response_id": "{{rootId}}" }""");
|
||||
string branchTwoId = await PostAndGetResponseIdAsync(client, $$"""{ "input": "branch two", "previous_response_id": "{{rootId}}" }""");
|
||||
_ = await PostAndGetResponseIdAsync(client, $$"""{ "input": "continue one", "previous_response_id": "{{branchOneId}}" }""");
|
||||
_ = await PostAndGetResponseIdAsync(client, $$"""{ "input": "continue two", "previous_response_id": "{{branchTwoId}}" }""");
|
||||
|
||||
// Assert: user-message counts per turn are [1, 2, 2, 3, 3]. Both branches build on the root (2) and not
|
||||
// on each other; each continuation builds only on its own branch (3). Shared/leaked state would instead
|
||||
// yield an increasing chain such as [1, 2, 3, 4, 5].
|
||||
int[] userCounts = recorder.CallHistory
|
||||
.Select(call => call.Count(m => m.Role == ChatRole.User))
|
||||
.ToArray();
|
||||
Assert.Equal(s_independentBranchUserCounts, userCounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_ConversationId_AdvancesMutableHeadAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var recorder = new TestHelpers.ConversationMemoryMockChatClient("ok");
|
||||
HttpClient client = await this.StartAgentHostAsync(recorder);
|
||||
|
||||
// Act: two turns on the SAME stable conversation id.
|
||||
_ = await client.PostAsync(new Uri("/responses", UriKind.Relative), JsonContent("""{ "input": "turn one", "conversation": "conv_stable" }"""));
|
||||
_ = await client.PostAsync(new Uri("/responses", UriKind.Relative), JsonContent("""{ "input": "turn two", "conversation": "conv_stable" }"""));
|
||||
|
||||
// Assert: the conversation head advanced in place — turn two saw turn one's message (2 user messages),
|
||||
// rather than resetting to a fresh session (which would show 1). This is the mutable-head write-back.
|
||||
int[] userCounts = recorder.CallHistory
|
||||
.Select(call => call.Count(m => m.Role == ChatRole.User))
|
||||
.ToArray();
|
||||
Assert.Equal(s_conversationAdvanceUserCounts, userCounts);
|
||||
}
|
||||
|
||||
private static async Task<string> PostAndGetResponseIdAsync(HttpClient client, string body)
|
||||
{
|
||||
HttpResponseMessage response = await client.PostAsync(new Uri("/responses", UriKind.Relative), JsonContent(body));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return doc.RootElement.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentRoute_MalformedBody_ReturnsBadRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient client = await this.StartAgentHostAsync(new TestHelpers.ConversationMemoryMockChatClient("ok"));
|
||||
|
||||
// Act (missing the required "input" field)
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
JsonContent("""{ "model": "x" }"""));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowRoute_RunThenResume_AdvancesCheckpointAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange: a two-agent sequential workflow behind an app-owned route with checkpoint resume keyed by
|
||||
// the stable conversation id.
|
||||
HttpClient client = await this.StartWorkflowHostAsync();
|
||||
const string ConversationId = "conv_it_1";
|
||||
|
||||
// Act: first turn runs the workflow forward; second turn (same conversation) resumes from the checkpoint.
|
||||
HttpResponseMessage firstResponse = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
JsonContent($$"""{ "input": "draft this", "conversation": "{{ConversationId}}" }"""));
|
||||
HttpResponseMessage secondResponse = await client.PostAsync(
|
||||
new Uri("/responses", UriKind.Relative),
|
||||
JsonContent($$"""{ "input": "and again", "conversation": "{{ConversationId}}" }"""));
|
||||
|
||||
// Assert: both turns succeed and produce Responses-shaped payloads.
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
using JsonDocument doc = JsonDocument.Parse(await secondResponse.Content.ReadAsStringAsync());
|
||||
Assert.StartsWith("resp_", doc.RootElement.GetProperty("id").GetString());
|
||||
Assert.Equal(ConversationId, doc.RootElement.GetProperty("conversation").GetProperty("id").GetString());
|
||||
}
|
||||
|
||||
private async Task<HttpClient> StartAgentHostAsync(IChatClient chatClient)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
var agent = new ChatClientAgent(chatClient, instructions: "You are a helpful assistant.", name: "assistant");
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
|
||||
|
||||
this._app.MapPost("/responses", async (JsonElement body, HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
OpenAIResponsesRunRequest run;
|
||||
try
|
||||
{
|
||||
run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, ct);
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
|
||||
// A stable conversation id is a mutable head (write back under the same id); a previous_response_id
|
||||
// continuation or first turn is an immutable snapshot (save under the new response id so branches
|
||||
// from the same prior response stay independent).
|
||||
string? conversationId = run.ConversationId is { Length: > 0 } cid && cid == sessionStoreId ? cid : null;
|
||||
string saveId = conversationId ?? responseId;
|
||||
|
||||
if (body.TryGetProperty("stream", out JsonElement s) && s.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
http.Response.ContentType = "text/event-stream";
|
||||
var updates = agent.RunStreamingAsync(run.Messages, session, run.Options, ct);
|
||||
await foreach (string frame in OpenAIResponses.WriteResponseStreamAsync(updates, responseId, responseId, ct))
|
||||
{
|
||||
await http.Response.WriteAsync(frame, ct);
|
||||
}
|
||||
|
||||
await sessionStore.SaveSessionAsync(agent, saveId, session, ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct);
|
||||
await sessionStore.SaveSessionAsync(agent, saveId, session, ct);
|
||||
return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId));
|
||||
});
|
||||
|
||||
await this._app.StartAsync();
|
||||
this._client = this.ResolveTestClient();
|
||||
return this._client;
|
||||
}
|
||||
|
||||
private async Task<HttpClient> StartWorkflowHostAsync()
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
AIAgent writer = new ChatClientAgent(new TestHelpers.ConversationMemoryMockChatClient("draft"), name: "Writer");
|
||||
AIAgent reviewer = new ChatClientAgent(new TestHelpers.ConversationMemoryMockChatClient("final"), name: "Reviewer");
|
||||
Workflow workflow = AgentWorkflowBuilder.BuildSequential(workflowName: "WriteAndReview", agents: [writer, reviewer]);
|
||||
var state = new HostedWorkflowState(workflow);
|
||||
|
||||
this._app.MapPost("/responses", async (JsonElement body, CancellationToken ct) =>
|
||||
{
|
||||
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
string sessionStoreId = run.ConversationId ?? OpenAIResponses.CreateResponseId();
|
||||
|
||||
HostedWorkflowRunResult result = await state.RunOrResumeAsync(sessionStoreId, run.Messages.ToList(), ct);
|
||||
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"{result.Events.Count} event(s)"));
|
||||
return Results.Json(OpenAIResponses.WriteResponse(response, OpenAIResponses.CreateResponseId(), sessionStoreId));
|
||||
});
|
||||
|
||||
await this._app.StartAsync();
|
||||
this._client = this.ResolveTestClient();
|
||||
return this._client;
|
||||
}
|
||||
|
||||
private HttpClient ResolveTestClient()
|
||||
{
|
||||
TestServer server = this._app!.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
return server.CreateClient();
|
||||
}
|
||||
|
||||
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._client?.Dispose();
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="OpenAIResponses"/> helper facade.
|
||||
/// </summary>
|
||||
public class OpenAIResponsesTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToAgentRunRequest_StringInput_ProducesUserMessage()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""{ "input": "Hello there" }""");
|
||||
|
||||
// Act
|
||||
var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement);
|
||||
|
||||
// Assert
|
||||
var message = Assert.Single(request.Messages);
|
||||
Assert.Equal(ChatRole.User, message.Role);
|
||||
Assert.Equal("Hello there", message.Text);
|
||||
Assert.Null(request.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionStoreId_PreviousResponseId_IsReturned()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""{ "input": "hi", "previous_response_id": "resp_abc" }""");
|
||||
var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal("resp_abc", OpenAIResponses.GetSessionStoreId(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionStoreId_ConversationId_IsReturned()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""{ "input": "hi", "conversation": "conv_xyz" }""");
|
||||
var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal("conv_xyz", OpenAIResponses.GetSessionStoreId(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionStoreId_BothPresent_PrefersPreviousResponseId()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""{ "input": "hi", "previous_response_id": "resp_abc", "conversation": "conv_xyz" }""");
|
||||
var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal("resp_abc", OpenAIResponses.GetSessionStoreId(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionStoreId_NoContinuationKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""{ "input": "hi" }""");
|
||||
var request = OpenAIResponses.ToAgentRunRequest(doc.RootElement);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Null(OpenAIResponses.GetSessionStoreId(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentRunRequest_InvalidBody_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange (missing the required "input" field)
|
||||
using var doc = JsonDocument.Parse("""{ "model": "x" }""");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("body", () => OpenAIResponses.ToAgentRunRequest(doc.RootElement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAgentRunRequest_MalformedBody_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange (an array is not a valid Responses body)
|
||||
using var doc = JsonDocument.Parse("""[ 1, 2, 3 ]""");
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>("body", () => OpenAIResponses.ToAgentRunRequest(doc.RootElement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateResponseId_HasResponsePrefix()
|
||||
{
|
||||
// Act
|
||||
string id = OpenAIResponses.CreateResponseId();
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("resp_", id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteResponse_RendersIdAndOutputText()
|
||||
{
|
||||
// Arrange
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hello from the agent"));
|
||||
const string ResponseId = "resp_test123";
|
||||
|
||||
// Act
|
||||
JsonElement payload = OpenAIResponses.WriteResponse(response, ResponseId, conversationId: "conv_1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ResponseId, payload.GetProperty("id").GetString());
|
||||
Assert.Equal("conv_1", payload.GetProperty("conversation").GetProperty("id").GetString());
|
||||
Assert.Contains("Hello from the agent", payload.GetRawText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal human-in-the-loop workflow whose start executor forwards its input to a
|
||||
/// <see cref="RequestPort"/>, so the workflow emits a <see cref="RequestInfoEvent"/> and halts awaiting an
|
||||
/// external response. Used to verify that resuming such a workflow does not block indefinitely.
|
||||
/// </summary>
|
||||
internal static class ApprovalGateWorkflow
|
||||
{
|
||||
internal const string RequestPortId = "approval";
|
||||
|
||||
internal static Workflow Build()
|
||||
{
|
||||
var gate = new ApprovalGateExecutor("gate", RequestPortId);
|
||||
RequestPort port = RequestPort.Create<ApprovalRequest, string>(RequestPortId);
|
||||
var finalize = new FinalizeExecutor("finalize");
|
||||
|
||||
return new WorkflowBuilder(gate)
|
||||
.AddEdge(gate, port)
|
||||
.AddEdge(port, finalize)
|
||||
.WithOutputFrom(finalize)
|
||||
.Build();
|
||||
}
|
||||
|
||||
internal sealed record ApprovalRequest(string Prompt);
|
||||
|
||||
private sealed class ApprovalGateExecutor(string id, string requestPortId) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.SendsMessage<ApprovalRequest>();
|
||||
|
||||
private ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.SendMessageAsync(new ApprovalRequest(input), requestPortId, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class FinalizeExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(string approval, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"approved:{approval}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a workflow whose start executor accepts a typed <see cref="WriterBrief"/> (not
|
||||
/// <c>List<ChatMessage></c>) and yields a formatted string. Used to demonstrate that an application can
|
||||
/// adapt Responses input into a workflow's native start-executor input via the generic
|
||||
/// <c>RunOrResumeAsync<TInput></c>.
|
||||
/// </summary>
|
||||
internal static class BriefWorkflow
|
||||
{
|
||||
internal sealed record WriterBrief(string Topic, string Style);
|
||||
|
||||
internal static Workflow Build()
|
||||
{
|
||||
var writer = new BriefExecutor("brief");
|
||||
return new WorkflowBuilder(writer)
|
||||
.WithOutputFrom(writer)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class BriefExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<WriterBrief>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(WriterBrief brief, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"[{brief.Style}] {brief.Topic}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="ILoggerFactory"/> that captures every log entry it produces, for asserting on
|
||||
/// diagnostics emitted by the system under test.
|
||||
/// </summary>
|
||||
internal sealed class CapturingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(this.Entries);
|
||||
|
||||
public void AddProvider(ILoggerProvider provider)
|
||||
{
|
||||
// No-op: this factory always produces capturing loggers.
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// No-op.
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger(List<(LogLevel Level, string Message)> entries) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a non-chat-protocol workflow whose single executor keeps a running count in workflow state and
|
||||
/// yields <c>count:N</c>. Because the count is checkpointed, a genuine resume observes the accumulated value
|
||||
/// (for example <c>count:2</c> on the second turn), whereas a fresh run restarts at <c>count:1</c>. Used to
|
||||
/// prove that a turn actually resumed from a prior checkpoint rather than starting over.
|
||||
/// </summary>
|
||||
internal static class CountingWorkflow
|
||||
{
|
||||
internal static Workflow Build()
|
||||
{
|
||||
var counter = new CountingExecutor("counter");
|
||||
return new WorkflowBuilder(counter)
|
||||
.WithOutputFrom(counter)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class CountingExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private async ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
int count = await context.ReadOrInitStateAsync("count", () => 0, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
await context.QueueStateUpdateAsync("count", count, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.YieldOutputAsync($"count:{count}", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -387,11 +387,14 @@ public class DelegatingAgentSessionStoreTests
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
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)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a workflow whose start executor, in a single superstep, both emits an external request (to a
|
||||
/// <see cref="RequestPort"/>) and queues a message to a downstream executor that yields output. Used to
|
||||
/// verify that resuming does not truncate the turn at the request-bearing superstep before the downstream
|
||||
/// executor runs.
|
||||
/// </summary>
|
||||
internal static class FanOutRequestWorkflow
|
||||
{
|
||||
internal const string RequestPortId = "approval";
|
||||
internal const string DownstreamPrefix = "downstream:";
|
||||
|
||||
internal static Workflow Build()
|
||||
{
|
||||
var start = new FanOutExecutor("start", RequestPortId, "downstream");
|
||||
RequestPort port = RequestPort.Create<ApprovalRequest, string>(RequestPortId);
|
||||
var downstream = new DownstreamExecutor("downstream");
|
||||
|
||||
return new WorkflowBuilder(start)
|
||||
.AddEdge(start, port)
|
||||
.AddEdge(start, downstream)
|
||||
.WithOutputFrom(downstream)
|
||||
.Build();
|
||||
}
|
||||
|
||||
internal sealed record ApprovalRequest(string Prompt);
|
||||
|
||||
private sealed class FanOutExecutor(string id, string requestPortId, string downstreamId) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.SendsMessage<ApprovalRequest>()
|
||||
.SendsMessage<string>();
|
||||
|
||||
private async ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Same superstep: emit an external request AND queue downstream work.
|
||||
await context.SendMessageAsync(new ApprovalRequest(input), requestPortId, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(input, downstreamId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DownstreamExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"{DownstreamPrefix}{message}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a non-chat-protocol workflow whose executor signals when it starts and then blocks on a
|
||||
/// test-controlled gate before finishing. This lets a test hold a turn "inside" the workflow and observe
|
||||
/// whether a second, concurrent turn for the same holder is allowed to run at the same time.
|
||||
/// </summary>
|
||||
internal static class GatedCountingWorkflow
|
||||
{
|
||||
internal static Workflow Build(SemaphoreSlim entered, Task release)
|
||||
{
|
||||
var gated = new GatedExecutor("gated", entered, release);
|
||||
return new WorkflowBuilder(gated)
|
||||
.WithOutputFrom(gated)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class GatedExecutor(string id, SemaphoreSlim entered, Task release) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private async ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Signal that this turn has started running inside the workflow, then wait for the test to release.
|
||||
entered.Release();
|
||||
await release.ConfigureAwait(false);
|
||||
|
||||
int count = await context.ReadOrInitStateAsync("count", () => 0, cancellationToken).ConfigureAwait(false);
|
||||
count++;
|
||||
await context.QueueStateUpdateAsync("count", count, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.YieldOutputAsync($"count:{count}", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="HostedWorkflowState"/> class.
|
||||
/// </summary>
|
||||
public class HostedWorkflowStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NullWorkflow_Throws() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("workflow", () => new HostedWorkflowState((Workflow)null!));
|
||||
|
||||
[Fact]
|
||||
public void TryGetCheckpoint_UnknownSession_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateTestWorkflow());
|
||||
|
||||
// Act
|
||||
bool found = state.TryGetCheckpoint("unknown", out var checkpoint);
|
||||
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(checkpoint);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task RunOrResumeAsync_InvalidSessionId_ThrowsAsync(string? sessionId)
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateTestWorkflow());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => state.RunOrResumeAsync(sessionId!, "input").AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_NullInput_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateTestWorkflow());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>("input", () => state.RunOrResumeAsync<string>("s1", null!).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_FirstTurn_RunsAndRecordsCheckpointAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateEchoWorkflow());
|
||||
|
||||
// Act
|
||||
HostedWorkflowRunResult result = await state.RunOrResumeAsync("s1", InputMessages("hello"));
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Events);
|
||||
Assert.NotNull(result.Checkpoint);
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? checkpoint));
|
||||
Assert.Same(result.Checkpoint, checkpoint);
|
||||
Assert.Contains("hello", OutputText(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_SecondTurn_ResumesWithNewInputAndCompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateEchoWorkflow());
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", InputMessages("hello"));
|
||||
CheckpointInfo? firstCheckpoint = first.Checkpoint;
|
||||
|
||||
// Act: the second turn must restore the checkpoint and run forward with the NEW input.
|
||||
// A regression here (resuming with no input) would hang, so guard with a timeout.
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", InputMessages("world"))
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: the resumed turn processed the new input and advanced the checkpoint.
|
||||
Assert.NotEmpty(second.Events);
|
||||
Assert.Contains("world", OutputText(second));
|
||||
Assert.NotNull(second.Checkpoint);
|
||||
Assert.NotSame(firstCheckpoint, second.Checkpoint);
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? head));
|
||||
Assert.Same(second.Checkpoint, head);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_ResumeWithPendingRequest_DoesNotBlockAsync()
|
||||
{
|
||||
// Arrange: a human-in-the-loop workflow whose start executor forwards its input to a request port,
|
||||
// so the workflow emits a RequestInfoEvent and halts awaiting an external response.
|
||||
var state = new HostedWorkflowState(ApprovalGateWorkflow.Build());
|
||||
|
||||
// First turn halts at the pending request (the non-blocking baseline).
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "approve deploy")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.Contains(first.Events, e => e is RequestInfoEvent);
|
||||
Assert.NotNull(first.Checkpoint);
|
||||
|
||||
// Act: resuming a workflow that halts at a pending request must also return instead of blocking
|
||||
// forever. A regression (blocking drain) hangs here, so guard with a timeout.
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "approve deploy again")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: the resumed turn surfaced the pending request and returned.
|
||||
Assert.Contains(second.Events, e => e is RequestInfoEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_ResumeMakesNoProgress_LogsWarningAsync()
|
||||
{
|
||||
// Arrange: a non-chat-protocol workflow that completes on the first turn.
|
||||
var loggerFactory = new CapturingLoggerFactory();
|
||||
var state = new HostedWorkflowState(StringEchoWorkflow.Build(), loggerFactory: loggerFactory);
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "hello");
|
||||
Assert.NotEmpty(first.Events);
|
||||
Assert.NotNull(first.Checkpoint);
|
||||
|
||||
// Act: resume with an input the start executor cannot handle, so the turn drives no work.
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", 42);
|
||||
|
||||
// Assert: a resume that produced no events is surfaced as a warning (possible stale checkpoint /
|
||||
// mismatched input).
|
||||
Assert.Empty(second.Events);
|
||||
Assert.Contains(loggerFactory.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_CursorMiss_ResumesFromManagerLatestCheckpointAsync()
|
||||
{
|
||||
// Arrange: a shared checkpoint manager stands in for durable storage that outlives the in-memory
|
||||
// cursor. The first holder runs one turn; a counting workflow records count:1 in the checkpoint.
|
||||
var manager = CheckpointManager.CreateInMemory();
|
||||
var first = new HostedWorkflowState(CountingWorkflow.Build(), manager);
|
||||
HostedWorkflowRunResult firstResult = await first.RunOrResumeAsync("s1", "go");
|
||||
Assert.Contains("count:1", StringOutput(firstResult));
|
||||
|
||||
// Act: a NEW holder over the SAME manager (fresh cursor, e.g. after a process restart) runs the
|
||||
// session again. With durable read-through it resumes from the manager's latest checkpoint.
|
||||
var second = new HostedWorkflowState(CountingWorkflow.Build(), manager);
|
||||
HostedWorkflowRunResult resumed = await second.RunOrResumeAsync("s1", "go")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: the count advanced to 2, proving it resumed from the prior checkpoint rather than
|
||||
// restarting from scratch (which would yield count:1 again).
|
||||
Assert.Contains("count:2", StringOutput(resumed));
|
||||
Assert.True(second.TryGetCheckpoint("s1", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullFactory_Throws() =>
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>("workflowFactory", () => new HostedWorkflowState((Func<CancellationToken, ValueTask<Workflow>>)null!));
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_Factory_ConcurrentDifferentSessions_RunInParallelAsync()
|
||||
{
|
||||
// Arrange: factory mode builds a fresh workflow instance per run, so independent sessions are NOT
|
||||
// serialized. The gated workflow signals on entry and blocks on a shared gate; both instances share the
|
||||
// same gate so the test can hold both turns "inside" the workflow at once.
|
||||
using var entered = new SemaphoreSlim(0, 2);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var state = new HostedWorkflowState(
|
||||
_ => new ValueTask<Workflow>(GatedCountingWorkflow.Build(entered, release.Task)),
|
||||
CheckpointManager.CreateInMemory());
|
||||
|
||||
// Act: start two turns for DIFFERENT sessions.
|
||||
Task<HostedWorkflowRunResult> first = state.RunOrResumeAsync("s1", "go").AsTask();
|
||||
Task<HostedWorkflowRunResult> second = state.RunOrResumeAsync("s2", "go").AsTask();
|
||||
|
||||
// Assert: BOTH turns enter the workflow before either is released — proving they run in parallel. In
|
||||
// shared-instance mode the second would wait on the holder lock and this second wait would time out.
|
||||
Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(10)), "the first turn should enter the workflow");
|
||||
Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(10)), "the second turn should enter concurrently in factory mode");
|
||||
|
||||
// Release both and let them complete.
|
||||
release.SetResult();
|
||||
HostedWorkflowRunResult[] results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Each independent session produced its own first-turn count.
|
||||
Assert.All(results, r => Assert.Contains("count:1", StringOutput(r)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_Factory_FirstTurnThenResume_AdvancesCheckpointAsync()
|
||||
{
|
||||
// Arrange: factory mode with a fresh instance per run. A resume must rehydrate a fresh instance from the
|
||||
// session's checkpoint in the shared manager.
|
||||
var state = new HostedWorkflowState(_ => new ValueTask<Workflow>(CountingWorkflow.Build()));
|
||||
|
||||
// Act: two turns for the same session.
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "go");
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go");
|
||||
|
||||
// Assert: the second turn resumed the first's state (count advanced 1 -> 2), proving a fresh instance
|
||||
// resumed from the checkpoint rather than starting over.
|
||||
Assert.Contains("count:1", StringOutput(first));
|
||||
Assert.Contains("count:2", StringOutput(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_CachedFactory_BuildsOnceAndReusesAsync()
|
||||
{
|
||||
// Arrange: a cached factory (cacheWorkflow: true) must build the workflow once and reuse it.
|
||||
int builds = 0;
|
||||
var state = new HostedWorkflowState(
|
||||
_ =>
|
||||
{
|
||||
Interlocked.Increment(ref builds);
|
||||
return new ValueTask<Workflow>(CountingWorkflow.Build());
|
||||
},
|
||||
cacheWorkflow: true);
|
||||
|
||||
// Act: two turns for the same session.
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "go");
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go");
|
||||
|
||||
// Assert: the factory ran exactly once (cached), and the reused instance still advanced state 1 -> 2.
|
||||
Assert.Equal(1, builds);
|
||||
Assert.Contains("count:1", StringOutput(first));
|
||||
Assert.Contains("count:2", StringOutput(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_CachedFactory_RetriesAfterFaultedBuildAsync()
|
||||
{
|
||||
// Arrange: a cached factory whose first build faults, then succeeds. A faulted cached build must not be
|
||||
// reused; the next run must retry rather than re-observe the same failure forever. The first build faults
|
||||
// asynchronously so the faulted Task is actually cached, exercising the reuse guard.
|
||||
int builds = 0;
|
||||
var state = new HostedWorkflowState(
|
||||
_ =>
|
||||
{
|
||||
int attempt = Interlocked.Increment(ref builds);
|
||||
if (attempt == 1)
|
||||
{
|
||||
return new ValueTask<Workflow>(Task.FromException<Workflow>(new InvalidOperationException("transient build failure")));
|
||||
}
|
||||
|
||||
return new ValueTask<Workflow>(CountingWorkflow.Build());
|
||||
},
|
||||
cacheWorkflow: true);
|
||||
|
||||
// Act & Assert: the first run surfaces the build failure.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => state.RunOrResumeAsync("s1", "go").AsTask());
|
||||
|
||||
// A later run rebuilds (the faulted task was not cached) and succeeds.
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go");
|
||||
Assert.Equal(2, builds);
|
||||
Assert.Contains("count:1", StringOutput(second));
|
||||
|
||||
// Once a successful build is cached, further runs reuse it (no additional builds).
|
||||
HostedWorkflowRunResult third = await state.RunOrResumeAsync("s1", "go");
|
||||
Assert.Equal(2, builds);
|
||||
Assert.Contains("count:2", StringOutput(third));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_UncachedFactory_BuildsPerRunAsync()
|
||||
{
|
||||
// Arrange: the default (uncached) factory builds a fresh instance for every run.
|
||||
int builds = 0;
|
||||
var state = new HostedWorkflowState(
|
||||
_ =>
|
||||
{
|
||||
Interlocked.Increment(ref builds);
|
||||
return new ValueTask<Workflow>(CountingWorkflow.Build());
|
||||
});
|
||||
|
||||
// Act: two turns for the same session.
|
||||
_ = await state.RunOrResumeAsync("s1", "go");
|
||||
_ = await state.RunOrResumeAsync("s1", "go");
|
||||
|
||||
// Assert: the factory ran once per run.
|
||||
Assert.Equal(2, builds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_NonChatWorkflow_ResumesWithNewInputAsync()
|
||||
{
|
||||
// Arrange: a non-chat-protocol workflow (string start executor), so the resume path sends the input
|
||||
// without a TurnToken.
|
||||
var state = new HostedWorkflowState(CountingWorkflow.Build());
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "go");
|
||||
Assert.Contains("count:1", StringOutput(first));
|
||||
|
||||
// Act
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "go")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: the non-chat resume carried state and advanced the checkpoint.
|
||||
Assert.Contains("count:2", StringOutput(second));
|
||||
Assert.NotNull(second.Checkpoint);
|
||||
Assert.NotSame(first.Checkpoint, second.Checkpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_ThirdTurn_KeepsAdvancingCheckpointAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateEchoWorkflow());
|
||||
|
||||
// Act: three turns on the same session.
|
||||
HostedWorkflowRunResult r1 = await state.RunOrResumeAsync("s1", InputMessages("a"));
|
||||
HostedWorkflowRunResult r2 = await state.RunOrResumeAsync("s1", InputMessages("b"))
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
HostedWorkflowRunResult r3 = await state.RunOrResumeAsync("s1", InputMessages("c"))
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: the cursor keeps advancing past the second turn, and the head reflects the latest turn.
|
||||
Assert.Contains("c", OutputText(r3));
|
||||
Assert.NotNull(r1.Checkpoint);
|
||||
Assert.NotNull(r3.Checkpoint);
|
||||
Assert.NotSame(r1.Checkpoint, r2.Checkpoint);
|
||||
Assert.NotSame(r2.Checkpoint, r3.Checkpoint);
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? head));
|
||||
Assert.Same(r3.Checkpoint, head);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeStreamingAsync_StreamsEventsAndResumesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateEchoWorkflow());
|
||||
|
||||
// Act: first turn streamed.
|
||||
List<WorkflowEvent> firstEvents = [];
|
||||
await foreach (WorkflowEvent evt in state.RunOrResumeStreamingAsync("s1", InputMessages("hello")))
|
||||
{
|
||||
firstEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert: events streamed and the checkpoint was recorded after the stream completed.
|
||||
Assert.NotEmpty(firstEvents);
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? firstCheckpoint));
|
||||
Assert.NotNull(firstCheckpoint);
|
||||
|
||||
// Act: second turn streamed via the resume path with new input.
|
||||
List<WorkflowEvent> secondEvents = [];
|
||||
await foreach (WorkflowEvent evt in state.RunOrResumeStreamingAsync("s1", InputMessages("world")))
|
||||
{
|
||||
secondEvents.Add(evt);
|
||||
}
|
||||
|
||||
// Assert: the resumed stream processed the new input and advanced the checkpoint.
|
||||
string output = string.Concat(
|
||||
secondEvents
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.Select(e => e.Data)
|
||||
.OfType<IEnumerable<ChatMessage>>()
|
||||
.SelectMany(messages => messages)
|
||||
.Select(m => m.Text));
|
||||
Assert.Contains("world", output);
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? secondCheckpoint));
|
||||
Assert.NotSame(firstCheckpoint, secondCheckpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_AdaptsResponsesInputToTypedStartExecutorAsync()
|
||||
{
|
||||
// Arrange: a workflow whose start executor takes a typed WriterBrief rather than List<ChatMessage>.
|
||||
// The application adapts the Responses input into that type before calling RunOrResumeAsync via the
|
||||
// generic TInput.
|
||||
var state = new HostedWorkflowState(BriefWorkflow.Build());
|
||||
|
||||
// Simulate parsing a structured Responses text payload into the start executor's input type.
|
||||
const string ResponsesText = "{\"topic\":\"electric SUV\",\"style\":\"playful\"}";
|
||||
using JsonDocument doc = JsonDocument.Parse(ResponsesText);
|
||||
var brief = new BriefWorkflow.WriterBrief(
|
||||
doc.RootElement.GetProperty("topic").GetString()!,
|
||||
doc.RootElement.GetProperty("style").GetString()!);
|
||||
|
||||
// Act
|
||||
HostedWorkflowRunResult result = await state.RunOrResumeAsync("s1", brief);
|
||||
|
||||
// Assert: the adapted input drove the typed start executor.
|
||||
Assert.Contains("[playful] electric SUV", StringOutput(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_ResumeWithRejectedInput_DoesNotHangAsync()
|
||||
{
|
||||
// Arrange: a non-chat human-in-the-loop workflow whose first turn emits a request and halts.
|
||||
var state = new HostedWorkflowState(ApprovalGateWorkflow.Build());
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "approve")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.Contains(first.Events, e => e is RequestInfoEvent);
|
||||
|
||||
// Act: resume with an input the start executor cannot handle (wrong type), so no superstep runs.
|
||||
// A drain that blocks on the restored pending request would hang here; guard with a timeout.
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", 42)
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: it returned (surfacing the restored pending request) rather than blocking indefinitely.
|
||||
Assert.Contains(second.Events, e => e is RequestInfoEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeAsync_ResumeSuperstepWithRequestAndDownstream_DoesNotTruncateAsync()
|
||||
{
|
||||
// Arrange: a workflow whose start executor, in one superstep, emits a request AND queues a message to
|
||||
// a downstream executor that yields output. The first turn establishes a checkpoint.
|
||||
var state = new HostedWorkflowState(FanOutRequestWorkflow.Build());
|
||||
HostedWorkflowRunResult first = await state.RunOrResumeAsync("s1", "one")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.Contains(first.Events, e => e is RequestInfoEvent);
|
||||
|
||||
// Act: resume with new input, which again fans out to the request port and the downstream executor.
|
||||
HostedWorkflowRunResult second = await state.RunOrResumeAsync("s1", "two")
|
||||
.AsTask()
|
||||
.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert: the resumed turn drained past the request-bearing superstep so the downstream output is
|
||||
// present (a drain that broke at the request would truncate it).
|
||||
Assert.Contains(second.Events, e => e is RequestInfoEvent);
|
||||
Assert.Contains(FanOutRequestWorkflow.DownstreamPrefix, StringOutput(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOrResumeStreamingAsync_AbandonedAfterCheckpoint_AdvancesCursorAsync()
|
||||
{
|
||||
// Arrange
|
||||
var state = new HostedWorkflowState(CreateEchoWorkflow());
|
||||
await foreach (WorkflowEvent _ in state.RunOrResumeStreamingAsync("s1", InputMessages("a")))
|
||||
{
|
||||
// Enumerate the first turn to completion so the cursor holds its head checkpoint.
|
||||
}
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? cp1));
|
||||
|
||||
// Act: abandon the second turn after a superstep has committed a checkpoint.
|
||||
await foreach (WorkflowEvent evt in state.RunOrResumeStreamingAsync("s1", InputMessages("b")))
|
||||
{
|
||||
if (evt is SuperStepCompletedEvent { CompletionInfo.Checkpoint: not null })
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert: the abandoned turn still advanced the cursor to the last committed checkpoint, so a later
|
||||
// turn resumes from there rather than re-running from the previous head.
|
||||
Assert.True(state.TryGetCheckpoint("s1", out CheckpointInfo? cp2));
|
||||
Assert.NotEqual(cp1, cp2);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> InputMessages(string text) => [new(ChatRole.User, text)];
|
||||
|
||||
private static string OutputText(HostedWorkflowRunResult result) =>
|
||||
string.Concat(
|
||||
result.Events
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.Select(e => e.Data)
|
||||
.OfType<IEnumerable<ChatMessage>>()
|
||||
.SelectMany(messages => messages)
|
||||
.Select(m => m.Text));
|
||||
|
||||
private static string StringOutput(HostedWorkflowRunResult result) =>
|
||||
string.Concat(
|
||||
result.Events
|
||||
.OfType<WorkflowOutputEvent>()
|
||||
.Select(e => e.Data)
|
||||
.OfType<string>());
|
||||
|
||||
private static Workflow CreateEchoWorkflow() =>
|
||||
AgentWorkflowBuilder.BuildSequential(workflowName: "echo", agents: [new TestEchoAgent(name: "echo")]);
|
||||
|
||||
private static Workflow CreateTestWorkflow()
|
||||
{
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.Name).Returns("testAgent");
|
||||
return AgentWorkflowBuilder.BuildSequential(workflowName: "wf", agents: [mockAgent.Object]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentSessionStore.DeleteSessionAsync"/> across the in-box stores.
|
||||
/// </summary>
|
||||
public class InMemoryAgentSessionStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_RemovesStoredSession_SoNextGetCreatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var stored = JsonSerializer.SerializeToElement(new { marker = "stored" });
|
||||
var restoredSession = new TestAgentSession();
|
||||
var createdSession = new TestAgentSession();
|
||||
var agent = new Mock<AIAgent>();
|
||||
agent.Protected()
|
||||
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync", ItExpr.IsAny<AgentSession>(), ItExpr.IsAny<JsonSerializerOptions>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<JsonElement>(stored));
|
||||
agent.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync", ItExpr.IsAny<JsonElement>(), ItExpr.IsAny<JsonSerializerOptions>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(restoredSession));
|
||||
agent.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(createdSession));
|
||||
|
||||
var store = new InMemoryAgentSessionStore();
|
||||
|
||||
// Act & Assert
|
||||
await store.SaveSessionAsync(agent.Object, "s1", new TestAgentSession());
|
||||
Assert.Same(restoredSession, await store.GetSessionAsync(agent.Object, "s1"));
|
||||
|
||||
await store.DeleteSessionAsync(agent.Object, "s1");
|
||||
Assert.Same(createdSession, await store.GetSessionAsync(agent.Object, "s1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_UnknownId_DoesNotThrowAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentSessionStore();
|
||||
|
||||
// Act & Assert (no exception)
|
||||
await store.DeleteSessionAsync(new Mock<AIAgent>().Object, "missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_NoopStore_CompletesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new NoopAgentSessionStore();
|
||||
|
||||
// Act & Assert (no exception)
|
||||
await store.DeleteSessionAsync(new Mock<AIAgent>().Object, "any");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionAsync_StoreOptsOut_ThrowsNotSupportedAsync()
|
||||
{
|
||||
// Arrange: a store that chooses not to support deletion throws NotSupportedException itself.
|
||||
AgentSessionStore store = new ConcreteAgentSessionStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => store.DeleteSessionAsync(new Mock<AIAgent>().Object, "any").AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranchesAsync()
|
||||
{
|
||||
// Arrange: a real agent so the store round-trips the session through genuine serialize/deserialize,
|
||||
// and a stored session that carries some state to copy.
|
||||
AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant");
|
||||
var store = new InMemoryAgentSessionStore();
|
||||
|
||||
AgentSession original = await agent.CreateSessionAsync();
|
||||
original.StateBag.SetValue("marker", "v1");
|
||||
await store.SaveSessionAsync(agent, "s1", original);
|
||||
|
||||
// Act: two concurrent branches read the same stored id.
|
||||
AgentSession branchA = await store.GetSessionAsync(agent, "s1");
|
||||
AgentSession branchB = await store.GetSessionAsync(agent, "s1");
|
||||
|
||||
// Assert: each branch is an independent instance carrying the same content.
|
||||
Assert.NotSame(branchA, branchB);
|
||||
Assert.Equal("v1", branchA.StateBag.GetValue<string>("marker"));
|
||||
Assert.Equal("v1", branchB.StateBag.GetValue<string>("marker"));
|
||||
|
||||
// Mutating one branch must not affect the other branch or the stored snapshot.
|
||||
branchA.StateBag.SetValue("marker", "mutated");
|
||||
Assert.Equal("v1", branchB.StateBag.GetValue<string>("marker"));
|
||||
|
||||
AgentSession branchC = await store.GetSessionAsync(agent, "s1");
|
||||
Assert.Equal("v1", branchC.StateBag.GetValue<string>("marker"));
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// A chat client that is never invoked: these tests only create, serialize, and deserialize sessions.
|
||||
private sealed class NotInvokedChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -419,11 +419,14 @@ public class IsolationKeyScopedAgentSessionStoreTests
|
||||
/// </summary>
|
||||
private sealed class ConcreteAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
public override ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentSession());
|
||||
|
||||
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)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal non-chat-protocol workflow whose single executor accepts a <see cref="string"/> and yields
|
||||
/// it back as output. Used to exercise the resume path for workflows whose start executor does not accept
|
||||
/// <c>List<ChatMessage></c> (so no <see cref="TurnToken"/> is sent).
|
||||
/// </summary>
|
||||
internal static class StringEchoWorkflow
|
||||
{
|
||||
internal static Workflow Build()
|
||||
{
|
||||
var echo = new StringEchoExecutor("echo");
|
||||
return new WorkflowBuilder(echo)
|
||||
.WithOutputFrom(echo)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class StringEchoExecutor(string id) : Executor(id)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string>(this.HandleAsync))
|
||||
.YieldsOutput<string>();
|
||||
|
||||
private ValueTask HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync($"echo:{input}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic <see cref="AIAgent"/> used by workflow-hosting tests: it echoes each user message back as
|
||||
/// an assistant message (optionally prefixed) and supports session serialization so it can participate in
|
||||
/// checkpointed workflows.
|
||||
/// </summary>
|
||||
internal sealed class TestEchoAgent(string? id = null, string? name = null, string? prefix = null) : AIAgent
|
||||
{
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => name ?? base.Name;
|
||||
|
||||
public InMemoryChatHistoryProvider ChatHistoryProvider { get; } = new();
|
||||
|
||||
protected override async ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return serializedState.Deserialize<EchoAgentSession>(jsonSerializerOptions) ?? await this.CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is not EchoAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(EchoAgentSession)}' can be serialized by this agent.");
|
||||
}
|
||||
|
||||
return new(JsonSerializer.SerializeToElement(typedSession, jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
|
||||
new(new EchoAgentSession());
|
||||
|
||||
private ChatMessage UpdateSession(ChatMessage message, AgentSession? session = null)
|
||||
{
|
||||
this.ChatHistoryProvider.GetMessages(session).Add(message);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private List<ChatMessage> EchoMessages(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null)
|
||||
{
|
||||
List<ChatMessage> echoed = [];
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
this.UpdateSession(message, session);
|
||||
|
||||
if (message.Role == ChatRole.User && !string.IsNullOrEmpty(message.Text))
|
||||
{
|
||||
echoed.Add(this.UpdateSession(new ChatMessage(ChatRole.Assistant, $"{prefix}{message.Text}")
|
||||
{
|
||||
AuthorName = this.Name ?? this.Id,
|
||||
CreatedAt = DateTimeOffset.Now,
|
||||
MessageId = Guid.NewGuid().ToString("N")
|
||||
}, session));
|
||||
}
|
||||
}
|
||||
|
||||
return echoed;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentResponse result =
|
||||
new(this.EchoMessages(messages, session, options).ToList())
|
||||
{
|
||||
AgentId = this.Id,
|
||||
CreatedAt = DateTimeOffset.Now,
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string responseId = Guid.NewGuid().ToString("N");
|
||||
|
||||
foreach (ChatMessage message in this.EchoMessages(messages, session, options).ToList())
|
||||
{
|
||||
yield return
|
||||
new(message.Role, message.Contents)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
AuthorName = message.AuthorName,
|
||||
ResponseId = responseId,
|
||||
MessageId = message.MessageId,
|
||||
CreatedAt = message.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class EchoAgentSession : AgentSession
|
||||
{
|
||||
internal EchoAgentSession() { }
|
||||
|
||||
[JsonConstructor]
|
||||
internal EchoAgentSession(AgentSessionStateBag stateBag) : base(stateBag) { }
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -24,8 +24,8 @@ public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : Work
|
||||
private const string AgentName = "test-agent";
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false);
|
||||
public Task AutoSendFalseOnWorkflowConversationStillEmitsResponseEventsAsync() =>
|
||||
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: true);
|
||||
|
||||
[Fact]
|
||||
public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() =>
|
||||
@@ -58,8 +58,13 @@ public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : Work
|
||||
MockAgentProvider mockProvider = new();
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "hello "),
|
||||
new(ChatRole.Assistant, "world"),
|
||||
new(new ChatResponseUpdate { MessageId = "" }),
|
||||
new(new ChatResponseUpdate(ChatRole.Assistant, "hello ") { MessageId = " " }),
|
||||
new(new ChatResponseUpdate(ChatRole.Assistant, "world") { MessageId = "", Role = null }),
|
||||
new(ChatRole.Assistant, "!")
|
||||
{
|
||||
RawRepresentation = new object(),
|
||||
},
|
||||
];
|
||||
mockProvider
|
||||
.Setup(p => p.InvokeAgentAsync(
|
||||
@@ -109,6 +114,18 @@ public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : Work
|
||||
{
|
||||
Assert.Equal(updates.Length, updateEventCount);
|
||||
Assert.Equal(1, responseEventCount);
|
||||
|
||||
AgentResponseUpdateEvent[] updateEvents = events.OfType<AgentResponseUpdateEvent>().ToArray();
|
||||
Assert.Equal("", updateEvents[0].Update.MessageId);
|
||||
|
||||
string messageId = Assert.IsType<string>(updateEvents[1].Update.MessageId);
|
||||
Assert.NotEmpty(messageId);
|
||||
Assert.All(updateEvents.Skip(1), updateEvent => Assert.Equal(messageId, updateEvent.Update.MessageId));
|
||||
|
||||
AgentResponseEvent responseEvent = Assert.Single(events.OfType<AgentResponseEvent>());
|
||||
ChatMessage responseMessage = Assert.Single(responseEvent.Response.Messages);
|
||||
Assert.Equal(messageId, responseMessage.MessageId);
|
||||
Assert.Equal("hello world!", responseMessage.Text);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
+46
@@ -23,6 +23,28 @@ public sealed class InvokeAzureAgentExecutorTest(ITestOutputHelper output) : Wor
|
||||
// Arrange, Act & Assert
|
||||
Assert.Throws<DeclarativeModelException>(() => new InvokeAzureAgentExecutor(new InvokeAzureAgent(), new CapturingAgentProvider("text"), this.State));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task AutoSendDefaultsToTrueAndHonorsExplicitValueAsync(bool? autoSend, bool expectResponseEvents)
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
CapturingAgentProvider provider = new("response");
|
||||
InvokeAzureAgent model = this.CreateAutoSendModel(
|
||||
nameof(AutoSendDefaultsToTrueAndHonorsExplicitValueAsync),
|
||||
autoSend);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events =
|
||||
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectResponseEvents ? 1 : 0, events.OfType<AgentResponseUpdateEvent>().Count());
|
||||
Assert.Equal(expectResponseEvents ? 1 : 0, events.OfType<AgentResponseEvent>().Count());
|
||||
}
|
||||
|
||||
#region Input argument binding
|
||||
|
||||
[Fact]
|
||||
@@ -306,6 +328,30 @@ public sealed class InvokeAzureAgentExecutorTest(ITestOutputHelper output) : Wor
|
||||
return AssignParent<InvokeAzureAgent>(builder);
|
||||
}
|
||||
|
||||
private InvokeAzureAgent CreateAutoSendModel(string displayName, bool? autoSend)
|
||||
{
|
||||
AzureAgentOutput.Builder outputBuilder = new();
|
||||
if (autoSend.HasValue)
|
||||
{
|
||||
outputBuilder.AutoSend = new BoolExpression.Builder(BoolExpression.Literal(autoSend.Value));
|
||||
}
|
||||
|
||||
InvokeAzureAgent.Builder builder =
|
||||
new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
Agent =
|
||||
new AzureAgentUsage.Builder
|
||||
{
|
||||
Name = new StringExpression.Builder(StringExpression.Literal("BrainAutoSend")),
|
||||
},
|
||||
Output = outputBuilder,
|
||||
};
|
||||
|
||||
return AssignParent<InvokeAzureAgent>(builder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="ResponseAgentProvider"/> that returns a single configured text response and
|
||||
/// captures the input arguments supplied to <see cref="InvokeAgentAsync"/>.
|
||||
|
||||
+4
@@ -37,6 +37,10 @@ public sealed class SendActivityExecutorTest(ITestOutputHelper output) : Workflo
|
||||
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
|
||||
Assert.Equal(ChatRole.Assistant, message.Role);
|
||||
Assert.Equal("Test activity message", message.Text);
|
||||
|
||||
AgentResponseUpdateEvent updateEvent = Assert.Single(events.OfType<AgentResponseUpdateEvent>());
|
||||
Assert.Equal(agentEvent.Response.ResponseId, updateEvent.Update.ResponseId);
|
||||
Assert.Equal(message.MessageId, updateEvent.Update.MessageId);
|
||||
}
|
||||
|
||||
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
|
||||
|
||||
@@ -64,10 +64,77 @@ public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Test_AgentHostExecutor_AssignsStableMessageIdToContentfulStreamingUpdatesAsync(string? missingMessageId)
|
||||
{
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
AIAgentHostExecutor executor =
|
||||
new(
|
||||
new MissingMessageIdAgent(missingMessageId),
|
||||
new()
|
||||
{
|
||||
EmitAgentUpdateEvents = true,
|
||||
EmitAgentResponseEvents = true,
|
||||
});
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdateEvent[] updateEvents = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
|
||||
updateEvents.Should().HaveCount(3);
|
||||
updateEvents[0].Update.MessageId.Should().BeEmpty();
|
||||
|
||||
string? messageId = updateEvents[1].Update.MessageId;
|
||||
messageId.Should().NotBeNullOrEmpty();
|
||||
updateEvents.Skip(1).Should().OnlyContain(updateEvent => updateEvent.Update.MessageId == messageId);
|
||||
|
||||
AgentResponseEvent responseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
|
||||
ChatMessage responseMessage = responseEvent.Response.Messages.Should().ContainSingle().Subject;
|
||||
responseMessage.MessageId.Should().Be(messageId);
|
||||
responseMessage.Text.Should().Be("hello world");
|
||||
}
|
||||
|
||||
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
|
||||
private static ChatMessage AssistantMessage => new(ChatRole.Assistant, "Hello from Assistant!") { AuthorName = "User" };
|
||||
private static ChatMessage TestAgentMessage => new(ChatRole.Assistant, $"Hello from {TestAgentName}!") { AuthorName = TestAgentName };
|
||||
|
||||
private sealed class MissingMessageIdAgent(string? messageId) : TestReplayAgent
|
||||
{
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new AgentResponseUpdate(
|
||||
new ChatResponseUpdate
|
||||
{
|
||||
MessageId = "",
|
||||
ResponseId = "response-id",
|
||||
});
|
||||
yield return new AgentResponseUpdate(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "hello ")
|
||||
{
|
||||
MessageId = messageId,
|
||||
ResponseId = "response-id",
|
||||
});
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, "world")
|
||||
{
|
||||
MessageId = messageId,
|
||||
ResponseId = "response-id",
|
||||
Role = null,
|
||||
RawRepresentation = new object(),
|
||||
};
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true, false, false)]
|
||||
[InlineData(true, true, false, true)]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CheckpointManager.GetLatestCheckpointAsync"/>, which must return the most recently
|
||||
/// committed checkpoint for a session regardless of the backing store implementation.
|
||||
/// </summary>
|
||||
public class CheckpointManagerLatestTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetLatestCheckpointAsync_FileStore_ReturnsLastCommittedAsync()
|
||||
{
|
||||
// Arrange: commit a chain of checkpoints to a durable file store in a known order.
|
||||
using TempDirectory dir = new();
|
||||
using FileSystemJsonCheckpointStore store = new(dir);
|
||||
CheckpointManager manager = CheckpointManager.CreateJson(store);
|
||||
|
||||
const string SessionId = "session-latest";
|
||||
List<CheckpointInfo> committed = [];
|
||||
CheckpointInfo? parent = null;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
JsonElement value = JsonSerializer.SerializeToElement($"checkpoint-{i}");
|
||||
CheckpointInfo info = await store.CreateCheckpointAsync(SessionId, value, parent);
|
||||
committed.Add(info);
|
||||
parent = info;
|
||||
}
|
||||
|
||||
// Act
|
||||
IEnumerable<CheckpointInfo> index = await store.RetrieveIndexAsync(SessionId);
|
||||
CheckpointInfo? latest = await manager.GetLatestCheckpointAsync(SessionId);
|
||||
|
||||
// Assert: the durable index preserves commit order, so the latest checkpoint is the last committed.
|
||||
index.Should().Equal(committed, "the file-store index should be returned in commit order");
|
||||
latest.Should().Be(committed[^1]);
|
||||
}
|
||||
}
|
||||
+50
@@ -160,6 +160,56 @@ public sealed class FileSystemJsonCheckpointStoreTests
|
||||
#endif
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_ShouldNotReturnDuplicates_WhenIndexContainsDuplicateEntriesAsync()
|
||||
{
|
||||
// Arrange: create a checkpoint, then simulate a duplicated index entry on disk (the same CheckpointInfo
|
||||
// written a second time). This captures the reviewer's question of whether duplicate index entries can
|
||||
// surface as duplicate checkpoints; the load path guards each add with the membership set, so they cannot.
|
||||
using TempDirectory tempDirectory = new();
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
CheckpointInfo checkpoint;
|
||||
string fileName;
|
||||
|
||||
using (FileSystemJsonCheckpointStore store = new(tempDirectory))
|
||||
{
|
||||
checkpoint = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
fileName = store.GetFileNameForCheckpoint(sessionId, checkpoint);
|
||||
}
|
||||
|
||||
// Append a second, identical index line for the same checkpoint.
|
||||
string indexPath = Path.Combine(tempDirectory.FullName, "index.jsonl");
|
||||
string duplicateEntry = JsonSerializer.Serialize(new CheckpointFileIndexEntry(checkpoint, fileName));
|
||||
File.AppendAllText(indexPath, duplicateEntry + Environment.NewLine);
|
||||
|
||||
// Act: reopen so the store reloads the now-duplicated index from disk.
|
||||
using FileSystemJsonCheckpointStore reopenedStore = new(tempDirectory);
|
||||
CheckpointInfo[] index = (await reopenedStore.RetrieveIndexAsync(sessionId)).ToArray();
|
||||
|
||||
// Assert: the load path dedupes, so the checkpoint appears exactly once.
|
||||
index.Should().ContainSingle().Which.Should().Be(checkpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveIndexAsync_ShouldReturnDistinctCheckpointsInCommitOrderAsync()
|
||||
{
|
||||
// Arrange: several checkpoints for one session must each appear exactly once, in commit order.
|
||||
using TempDirectory tempDirectory = new();
|
||||
using FileSystemJsonCheckpointStore store = new(tempDirectory);
|
||||
string sessionId = Guid.NewGuid().ToString("N");
|
||||
|
||||
CheckpointInfo first = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
CheckpointInfo second = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
CheckpointInfo third = await store.CreateCheckpointAsync(sessionId, TestData);
|
||||
|
||||
// Act
|
||||
CheckpointInfo[] index = (await store.RetrieveIndexAsync(sessionId)).ToArray();
|
||||
|
||||
// Assert: no duplicates, and commit order preserved.
|
||||
index.Should().OnlyHaveUniqueItems();
|
||||
index.Should().Equal(first, second, third);
|
||||
}
|
||||
|
||||
private const string InvalidPathCharsWin32 = "\\/:*?\"<>|";
|
||||
private const string InvalidPathCharsUnix = "/";
|
||||
private const string InvalidPathCharsMacOS = "/:";
|
||||
|
||||
@@ -75,5 +75,71 @@ public class MagenticWorkflowBuilderTests
|
||||
Action build = () => builder.Build();
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*Stranger*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_WithResponseLanguage_ReturnsSameBuilderForChaining()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
MagenticWorkflowBuilder builder = new(manager);
|
||||
|
||||
// Act
|
||||
MagenticWorkflowBuilder chained = builder.WithResponseLanguage("English");
|
||||
|
||||
// Assert
|
||||
chained.Should().BeSameAs(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_WithPromptOverrides_ReturnsSameBuilderForChaining()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
MagenticWorkflowBuilder builder = new(manager);
|
||||
|
||||
// Act
|
||||
MagenticWorkflowBuilder chained = builder.WithPromptOverrides(new MagenticPromptOverrides { FinalAnswerPrompt = "custom {task}" });
|
||||
|
||||
// Assert
|
||||
chained.Should().BeSameAs(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_ProgressLedgerOverrideWithoutSchema_ThrowsOnBuild()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent worker = new(name: "Worker");
|
||||
|
||||
MagenticWorkflowBuilder builder = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(worker)
|
||||
.RequirePlanSignoff(false)
|
||||
.WithPromptOverrides(new MagenticPromptOverrides { ProgressLedgerPrompt = "Answer for {task} with no schema placeholder" });
|
||||
|
||||
// Act
|
||||
Action build = () => builder.Build();
|
||||
|
||||
// Assert
|
||||
build.Should().Throw<InvalidOperationException>().WithMessage("*{schema}*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_MagenticWorkflowBuilder_ProgressLedgerOverrideWithSchema_BuildsSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent manager = new(name: "Manager");
|
||||
TestEchoAgent worker = new(name: "Worker");
|
||||
|
||||
MagenticWorkflowBuilder builder = new MagenticWorkflowBuilder(manager)
|
||||
.AddParticipants(worker)
|
||||
.RequirePlanSignoff(false)
|
||||
.WithPromptOverrides(new MagenticPromptOverrides { ProgressLedgerPrompt = "Answer for {task}\n{schema}" });
|
||||
|
||||
// Act
|
||||
Action build = () => builder.Build();
|
||||
|
||||
// Assert
|
||||
build.Should().NotThrow();
|
||||
}
|
||||
}
|
||||
#pragma warning restore MAAIW001
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001;MAAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Magentic internal prompt templates: default English rendering with placeholder substitution,
|
||||
/// the concrete-language pin from WithResponseLanguage, and user prompt overrides (issue #6987).
|
||||
/// </summary>
|
||||
public class PromptTemplatesTests
|
||||
{
|
||||
// A concrete language name is pinned by the general directive when WithResponseLanguage is set.
|
||||
private const string ConcreteLanguageMarker = "in Esperanto";
|
||||
|
||||
private const string TaskText = "UNIQUE_TASK_TEXT";
|
||||
private const string FactsText = "UNIQUE_FACTS_TEXT";
|
||||
private const string PlanText = "UNIQUE_PLAN_TEXT";
|
||||
|
||||
private static MagenticTaskContext CreateContext(string? responseLanguage = null, MagenticPromptOverrides? overrides = null)
|
||||
{
|
||||
TestEchoAgent researcher = new(name: "Researcher");
|
||||
TestEchoAgent coder = new(name: "Coder");
|
||||
|
||||
MagenticTaskContext context = new(
|
||||
[new(ChatRole.User, TaskText)],
|
||||
[researcher, coder],
|
||||
new TaskLimits(),
|
||||
emitUpdateEvents: null,
|
||||
additionalProgressQuestions: [])
|
||||
{
|
||||
ResponseLanguage = responseLanguage,
|
||||
PromptOverrides = overrides,
|
||||
};
|
||||
|
||||
// Several prompts require a non-null ledger; set one so every prompt can be rendered.
|
||||
context.TaskLedger = new(new(ChatRole.Assistant, FactsText), new(ChatRole.Assistant, PlanText));
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static string RenderProsePrompt(MagenticTaskContext context, string promptName) => promptName switch
|
||||
{
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsPrompt) => context.ToTaskLedgerFactsPrompt(),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsUpdatePrompt) => context.ToTaskLedgerFactsUpdatePrompt(),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanPrompt) => context.ToTaskLedgerPlanPrompt(),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanUpdatePrompt) => context.ToTaskLedgerPlanUpdatePrompt(),
|
||||
nameof(PromptTemplateExtensions.ToFinalAnswerPrompt) => context.ToFinalAnswerPrompt(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(promptName), promptName, "Unknown prose prompt."),
|
||||
};
|
||||
|
||||
public static TheoryData<string> ProsePromptNames() =>
|
||||
[
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsPrompt),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerFactsUpdatePrompt),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanPrompt),
|
||||
nameof(PromptTemplateExtensions.ToTaskLedgerPlanUpdatePrompt),
|
||||
nameof(PromptTemplateExtensions.ToFinalAnswerPrompt),
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProsePromptNames))]
|
||||
public void ProsePrompt_Default_IsEnglish_WithoutLanguageDirective(string promptName)
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string prompt = RenderProsePrompt(context, promptName);
|
||||
|
||||
// Assert - no language directive by default (built-in English prompts are used as-is).
|
||||
prompt.Should().NotContain("Write your entire response in");
|
||||
prompt.Should().NotContain("Do not use any other language");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactsPrompt_Default_UsesOriginalEnglishHeadings_AndSubstitutesTask()
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string prompt = context.ToTaskLedgerFactsPrompt();
|
||||
|
||||
// Assert - reverted to the original English template (no per-language heading instruction); task substituted.
|
||||
prompt.Should().Contain("Your answer should use headings:");
|
||||
prompt.Should().Contain("GIVEN OR VERIFIED FACTS");
|
||||
prompt.Should().Contain(TaskText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLedgerPrompt_Default_HasSchemaContract_WithoutLanguageDirective()
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - schema/routing contract present; no language directive by default.
|
||||
prompt.Should().Contain("DO NOT OUTPUT ANYTHING OTHER THAN JSON");
|
||||
prompt.Should().Contain("next_speaker");
|
||||
prompt.Should().Contain("instruction_or_question");
|
||||
prompt.Should().NotContain("Do not translate the JSON keys");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProsePromptNames))]
|
||||
public void ProsePrompt_WithResponseLanguage_PinsConcreteLanguage(string promptName)
|
||||
{
|
||||
// Arrange - a distinctive language token that will not collide with other prompt text.
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto");
|
||||
|
||||
// Act
|
||||
string prompt = RenderProsePrompt(context, promptName);
|
||||
|
||||
// Assert - the concrete language directive is appended after the body.
|
||||
prompt.Should().Contain("Write your entire response in Esperanto");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLedgerPrompt_WithResponseLanguage_PinsConcreteLanguage_AndPreservesSchemaContract()
|
||||
{
|
||||
// Arrange
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto");
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - concrete language pinned for the free-text values...
|
||||
prompt.Should().Contain(ConcreteLanguageMarker);
|
||||
|
||||
// ...while the JSON-key/next_speaker protections and schema contract remain intact.
|
||||
prompt.Should().Contain("Do not translate the JSON keys");
|
||||
prompt.Should().Contain("must not be translated");
|
||||
prompt.Should().Contain("DO NOT OUTPUT ANYTHING OTHER THAN JSON");
|
||||
prompt.Should().Contain("next_speaker");
|
||||
prompt.Should().Contain("instruction_or_question");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullTaskLedgerPrompt_NeverAppendsLanguageDirective_AndSubstitutesFactsAndPlan()
|
||||
{
|
||||
// Arrange - even with a response language configured, the assembly-only full prompt gets no directive.
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto");
|
||||
|
||||
// Act
|
||||
string prompt = context.ToTaskLedgerFullPrompt();
|
||||
|
||||
// Assert
|
||||
prompt.Should().NotContain("Write your entire response in");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().Contain(FactsText);
|
||||
prompt.Should().Contain(PlanText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptOverride_ReplacesBody_AndSubstitutesPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
MagenticPromptOverrides overrides = new() { TaskLedgerFactsPrompt = "CUSTOM facts request for {task}" };
|
||||
MagenticTaskContext context = CreateContext(overrides: overrides);
|
||||
|
||||
// Act
|
||||
string prompt = context.ToTaskLedgerFactsPrompt();
|
||||
|
||||
// Assert - the override body is used with placeholders substituted, and the default template is gone.
|
||||
prompt.Should().Contain("CUSTOM facts request for");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().NotContain("Ken Jennings-level");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromptOverride_ComposesWith_ResponseLanguage()
|
||||
{
|
||||
// Arrange
|
||||
MagenticPromptOverrides overrides = new() { FinalAnswerPrompt = "CUSTOM final answer for {task}" };
|
||||
MagenticTaskContext context = CreateContext(responseLanguage: "Esperanto", overrides: overrides);
|
||||
|
||||
// Act
|
||||
string prompt = context.ToFinalAnswerPrompt();
|
||||
|
||||
// Assert - override body + the concrete language directive appended after it.
|
||||
prompt.Should().Contain("CUSTOM final answer for");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().Contain(ConcreteLanguageMarker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLedgerOverride_InjectsSchemaViaPlaceholder()
|
||||
{
|
||||
// Arrange
|
||||
MagenticPromptOverrides overrides = new() { ProgressLedgerPrompt = "CUSTOM ledger for {task}\n{schema}" };
|
||||
MagenticTaskContext context = CreateContext(overrides: overrides);
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - the framework injects the JSON schema (keys) into the override via {schema}.
|
||||
prompt.Should().Contain("CUSTOM ledger for");
|
||||
prompt.Should().Contain(TaskText);
|
||||
prompt.Should().Contain("next_speaker");
|
||||
prompt.Should().Contain("instruction_or_question");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Substitute_DoesNotReExpandInsertedContent()
|
||||
{
|
||||
// Arrange - the task text itself contains placeholder-looking tokens that must NOT be re-substituted when
|
||||
// the later {team}/{schema} placeholders are filled (single-pass substitution).
|
||||
TestEchoAgent researcher = new(name: "Researcher");
|
||||
TestEchoAgent coder = new(name: "Coder");
|
||||
MagenticTaskContext context = new(
|
||||
[new(ChatRole.User, "Design a {schema} for the {team} data")],
|
||||
[researcher, coder],
|
||||
new TaskLimits(),
|
||||
emitUpdateEvents: null,
|
||||
additionalProgressQuestions: []);
|
||||
context.TaskLedger = new(new(ChatRole.Assistant, FactsText), new(ChatRole.Assistant, PlanText));
|
||||
|
||||
// Act
|
||||
string prompt = context.ToProgressLedgerPrompt();
|
||||
|
||||
// Assert - the task's literal {schema}/{team} tokens survive verbatim (not clobbered by later replacements)...
|
||||
prompt.Should().Contain("Design a {schema} for the {team} data");
|
||||
// ...while the real template placeholders were still substituted (team description + schema JSON keys).
|
||||
prompt.Should().Contain("Researcher");
|
||||
prompt.Should().Contain("next_speaker");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultPrompts_AreThePublicMagenticDefaultPrompts()
|
||||
{
|
||||
// Arrange - a default (no override) render should be built from the public MagenticDefaultPrompts template,
|
||||
// confirming MagenticDefaultPrompts is the single source of truth callers can base overrides on.
|
||||
MagenticTaskContext context = CreateContext();
|
||||
|
||||
// Act
|
||||
string factsPrompt = context.ToTaskLedgerFactsPrompt();
|
||||
string finalAnswerPrompt = context.ToFinalAnswerPrompt();
|
||||
|
||||
// Assert - the rendered prompt is the public default with {task} substituted.
|
||||
factsPrompt.Should().Be(MagenticDefaultPrompts.TaskLedgerFactsPrompt.Replace("{task}", context.Task));
|
||||
finalAnswerPrompt.Should().Be(MagenticDefaultPrompts.FinalAnswerPrompt.Replace("{task}", context.Task));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MagenticDefaultPrompts_ExposeExpectedPlaceholders()
|
||||
{
|
||||
// Assert - the published defaults keep the placeholders callers rely on when tailoring an override.
|
||||
MagenticDefaultPrompts.TaskLedgerFactsPrompt.Should().Contain("{task}");
|
||||
MagenticDefaultPrompts.TaskLedgerFactsUpdatePrompt.Should().Contain("{task}").And.Contain("{old_facts}");
|
||||
MagenticDefaultPrompts.TaskLedgerPlanPrompt.Should().Contain("{team}");
|
||||
MagenticDefaultPrompts.TaskLedgerPlanUpdatePrompt.Should().Contain("{team}");
|
||||
MagenticDefaultPrompts.TaskLedgerFullPrompt.Should().Contain("{task}").And.Contain("{team}").And.Contain("{facts}").And.Contain("{plan}");
|
||||
MagenticDefaultPrompts.ProgressLedgerPrompt.Should().Contain("{task}").And.Contain("{team}").And.Contain("{questions}").And.Contain("{schema}");
|
||||
MagenticDefaultPrompts.FinalAnswerPrompt.Should().Contain("{task}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user