Merge branch 'main' into local-branch-update-sample-validation-flow

This commit is contained in:
Tao Chen
2026-07-20 10:50:11 -07:00
714 changed files with 35599 additions and 16204 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ runs:
using: "composite"
steps:
- name: Set up uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version-file: "python/pyproject.toml"
enable-cache: true
@@ -46,4 +46,4 @@ runs:
- name: Install the project
shell: bash
run: |
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
cd python && uv sync --all-packages --all-extras --all-groups --prerelease=if-necessary-or-explicit
+22 -9
View File
@@ -1,25 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Resolve the issue author and check their team membership.
* Resolve the issue or pull request author and check their team membership.
*
* @param {object} opts
* @param {object} opts.github - Octokit REST client from actions/github-script
* @param {object} opts.context - GitHub Actions context
* @param {object} opts.core - GitHub Actions core toolkit
* @param {string} opts.teamSlug - Team slug to check membership against
* @param {string|number} opts.issueNumber - Issue number to resolve author for
* @param {string|number} opts.issueNumber - Issue or pull request number to resolve author for
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
*/
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
let author = context.payload.issue?.user?.login;
let author =
context.payload.issue?.user?.login ??
context.payload.pull_request?.user?.login;
if (!author) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(issueNumber),
});
author = issue.user?.login;
const number = Number(issueNumber);
if (context.payload.pull_request) {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: number,
});
author = pr.user?.login;
} else {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
});
author = issue.user?.login;
}
}
if (!author) {
@@ -0,0 +1,170 @@
// Copyright (c) Microsoft. All rights reserved.
const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
const SHA_PATTERN = /^[0-9a-f]{40}$/;
const BRANCH_PATTERN = /^[a-zA-Z0-9_./-]+$/;
function assertValidSha(sha, description) {
if (!SHA_PATTERN.test(sha)) {
throw new Error(`GitHub returned an invalid ${description} SHA.`);
}
}
function hasWritePermission(permissionData) {
return permissionData.user?.permissions?.push === true
|| ['admin', 'maintain', 'write'].includes(permissionData.permission);
}
function latestDecisiveReviews(reviews) {
const latestByReviewer = new Map();
const sortedReviews = [...reviews].sort((left, right) => {
const submittedComparison = (left.submitted_at || '').localeCompare(right.submitted_at || '');
return submittedComparison || Number(left.id) - Number(right.id);
});
for (const review of sortedReviews) {
const state = review.state?.toUpperCase();
const reviewer = review.user?.login?.toLowerCase();
if (reviewer && DECISIVE_REVIEW_STATES.has(state)) {
latestByReviewer.set(reviewer, review);
}
}
return latestByReviewer;
}
async function resolvePullRequest({ github, context, core, prNumber, requiredApprovals }) {
if (!/^[0-9]+$/.test(prNumber)) {
throw new Error('Invalid PR number. Only numeric values are allowed.');
}
const pullNumber = Number(prNumber);
const { data: pullRequest } = await github.rest.pulls.get({
...context.repo,
pull_number: pullNumber,
});
if (pullRequest.state !== 'open') {
throw new Error(`PR #${pullNumber} is not open (state: ${pullRequest.state}).`);
}
const headSha = pullRequest.head.sha;
const baseSha = pullRequest.base.sha;
assertValidSha(headSha, 'PR head');
assertValidSha(baseSha, 'PR base');
const reviews = await github.paginate(github.rest.pulls.listReviews, {
...context.repo,
pull_number: pullNumber,
per_page: 100,
});
const latestReviews = latestDecisiveReviews(reviews);
const author = pullRequest.user?.login?.toLowerCase();
const approvalCandidates = [...latestReviews.entries()]
.filter(([, review]) => review.state.toUpperCase() === 'APPROVED')
.filter(([, review]) => review.commit_id === headSha)
.filter(([reviewer]) => reviewer !== author);
const approvedMaintainers = [];
for (const [reviewer] of approvalCandidates) {
const { data: permissionData } = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username: reviewer,
});
if (hasWritePermission(permissionData)) {
approvedMaintainers.push(reviewer);
} else {
core.info(`Ignoring approval from ${reviewer}: reviewer does not have write permission.`);
}
}
if (approvedMaintainers.length < requiredApprovals) {
throw new Error(
`PR #${pullNumber} head ${headSha} requires ${requiredApprovals} approvals from unique `
+ `write-capable maintainers; found ${approvedMaintainers.length}.`,
);
}
core.info(
`PR #${pullNumber} head ${headSha} approved by: ${approvedMaintainers.join(', ')}.`,
);
return {
baseRef: baseSha,
checkoutRef: headSha,
description: `PR #${pullNumber}`,
};
}
async function resolveBranch({ github, context, core, branch }) {
if (!BRANCH_PATTERN.test(branch)) {
throw new Error(
'Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes '
+ 'are allowed.',
);
}
const [{ data: repository }, { data: targetBranch }] = await Promise.all([
github.rest.repos.get(context.repo),
github.rest.repos.getBranch({ ...context.repo, branch }),
]);
const { data: baseBranch } = await github.rest.repos.getBranch({
...context.repo,
branch: repository.default_branch,
});
const checkoutRef = targetBranch.commit.sha;
const baseRef = baseBranch.commit.sha;
assertValidSha(checkoutRef, 'branch head');
assertValidSha(baseRef, 'default branch');
core.info(`Branch ${branch} resolved to immutable commit ${checkoutRef}.`);
return {
baseRef,
checkoutRef,
description: `branch ${branch}`,
};
}
/**
* Resolve a manually requested integration-test target to an immutable commit.
*
* Pull requests must have fresh approvals from two unique write-capable
* maintainers for the exact head commit. Branches are limited to branches in
* the base repository and are pinned to their current commit.
*/
async function resolveIntegrationTestTarget({
github,
context,
core,
prNumber = '',
branch = '',
requiredApprovals = 2,
}) {
const normalizedPrNumber = prNumber.trim();
const normalizedBranch = branch.trim();
if (normalizedPrNumber && normalizedBranch) {
throw new Error('Please provide either a PR number or a branch name, not both.');
}
if (!normalizedPrNumber && !normalizedBranch) {
throw new Error('Please provide either a PR number or a branch name.');
}
if (normalizedPrNumber) {
return resolvePullRequest({
github,
context,
core,
prNumber: normalizedPrNumber,
requiredApprovals,
});
}
return resolveBranch({
github,
context,
core,
branch: normalizedBranch,
});
}
module.exports = resolveIntegrationTestTarget;
+51 -2
View File
@@ -16,7 +16,12 @@ const checkTeamMembership = require('../scripts/check_team_membership.js');
// Helpers
// ---------------------------------------------------------------------------
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
function createMocks({
payloadIssue = undefined,
payloadPullRequest = undefined,
apiUser = 'api-user',
teamState = 'active',
} = {}) {
const core = {
_infoMessages: [],
_failedMessages: [],
@@ -24,8 +29,16 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
setFailed(msg) { this._failedMessages.push(msg); },
};
const payload = {};
if (payloadIssue !== undefined) {
payload.issue = payloadIssue;
}
if (payloadPullRequest !== undefined) {
payload.pull_request = payloadPullRequest;
}
const context = {
payload: { issue: payloadIssue },
payload,
repo: { owner: 'test-org', repo: 'test-repo' },
};
@@ -36,6 +49,11 @@ function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState
data: { user: apiUser ? { login: apiUser } : null },
}),
},
pulls: {
get: async () => ({
data: { user: apiUser ? { login: apiUser } : null },
}),
},
teams: {
getByName: async () => ({}),
getMembershipForUserInOrg: async () => ({
@@ -64,6 +82,37 @@ describe('author resolution', () => {
assert.equal(result.author, 'payload-user');
});
it('resolves author from pull_request event payload', async () => {
const { github, context, core } = createMocks({
payloadPullRequest: { user: { login: 'pr-author' } },
});
let issuesGetCalled = false;
github.rest.issues.get = async () => {
issuesGetCalled = true;
return { data: { user: { login: 'api-user' } } };
};
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'pr-author');
assert.equal(issuesGetCalled, false);
});
it('resolves author via pulls API when pull_request payload user is null', async () => {
const { github, context, core } = createMocks({
payloadPullRequest: { user: null },
apiUser: 'fetched-pr-author',
});
let pullsGetCalled = false;
github.rest.pulls.get = async () => {
pullsGetCalled = true;
return { data: { user: { login: 'fetched-pr-author' } } };
};
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'fetched-pr-author');
assert.equal(pullsGetCalled, true);
});
it('resolves author via API when payload issue is absent', async () => {
const { github, context, core } = createMocks({ apiUser: 'api-user' });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
@@ -0,0 +1,212 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for resolve_integration_test_target.js.
*
* Run with: node --test .github/tests/test_resolve_integration_test_target.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const resolveIntegrationTestTarget = require('../scripts/resolve_integration_test_target.js');
const HEAD_SHA = 'a'.repeat(40);
const BASE_SHA = 'b'.repeat(40);
function review({
id,
login,
state = 'APPROVED',
commitId = HEAD_SHA,
submittedAt = `2026-07-13T00:00:${String(id).padStart(2, '0')}Z`,
}) {
return {
id,
state,
commit_id: commitId,
submitted_at: submittedAt,
user: { login },
};
}
function createMocks({
pullState = 'open',
pullAuthor = 'contributor',
reviews = [],
permissions = {},
} = {}) {
const core = {
infoMessages: [],
info(message) {
this.infoMessages.push(message);
},
};
const context = {
repo: { owner: 'microsoft', repo: 'agent-framework' },
};
const github = {
paginate: async () => reviews,
rest: {
pulls: {
get: async () => ({
data: {
state: pullState,
user: { login: pullAuthor },
head: { sha: HEAD_SHA },
base: { sha: BASE_SHA },
},
}),
listReviews: async () => {},
},
repos: {
get: async () => ({ data: { default_branch: 'main' } }),
getBranch: async ({ branch }) => ({
data: { commit: { sha: branch === 'main' ? BASE_SHA : HEAD_SHA } },
}),
getCollaboratorPermissionLevel: async ({ username }) => ({
data: permissions[username] || {
permission: 'read',
user: { permissions: { push: false } },
},
}),
},
},
};
return { core, context, github };
}
const WRITE_PERMISSION = {
permission: 'write',
user: { permissions: { push: true } },
};
describe('input validation', () => {
it('rejects missing and conflicting targets', async () => {
const mocks = createMocks();
await assert.rejects(
() => resolveIntegrationTestTarget(mocks),
/provide either a PR number or a branch name/,
);
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1', branch: 'feature' }),
/not both/,
);
});
it('rejects invalid PR numbers and branch names', async () => {
const mocks = createMocks();
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '1;echo' }),
/Invalid PR number/,
);
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, branch: 'feature branch' }),
/Invalid branch name/,
);
});
});
describe('pull request resolution', () => {
it('pins an open PR with two fresh write-capable approvals', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'maintainer-one' }),
review({ id: 2, login: 'maintainer-two' }),
],
permissions: {
'maintainer-one': WRITE_PERMISSION,
'maintainer-two': WRITE_PERMISSION,
},
});
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
assert.deepEqual(result, {
baseRef: BASE_SHA,
checkoutRef: HEAD_SHA,
description: 'PR #123',
});
});
it('rejects closed PRs', async () => {
const mocks = createMocks({ pullState: 'closed' });
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
/is not open/,
);
});
it('ignores stale, self, and read-only approvals', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'stale', commitId: 'c'.repeat(40) }),
review({ id: 2, login: 'contributor' }),
review({ id: 3, login: 'reader' }),
review({ id: 4, login: 'maintainer' }),
],
permissions: {
contributor: WRITE_PERMISSION,
reader: { permission: 'read', user: { permissions: { push: false } } },
maintainer: WRITE_PERMISSION,
},
});
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
/found 1/,
);
});
it('uses each reviewer latest decisive review and ignores later comments', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'changes-requested' }),
review({ id: 2, login: 'changes-requested', state: 'CHANGES_REQUESTED' }),
review({ id: 3, login: 'maintainer-one' }),
review({ id: 4, login: 'maintainer-one', state: 'COMMENTED' }),
review({ id: 5, login: 'maintainer-two' }),
],
permissions: {
'changes-requested': WRITE_PERMISSION,
'maintainer-one': WRITE_PERMISSION,
'maintainer-two': WRITE_PERMISSION,
},
});
const result = await resolveIntegrationTestTarget({ ...mocks, prNumber: '123' });
assert.equal(result.checkoutRef, HEAD_SHA);
});
it('does not count a dismissed approval', async () => {
const mocks = createMocks({
reviews: [
review({ id: 1, login: 'dismissed', state: 'DISMISSED' }),
review({ id: 2, login: 'maintainer' }),
],
permissions: {
dismissed: WRITE_PERMISSION,
maintainer: WRITE_PERMISSION,
},
});
await assert.rejects(
() => resolveIntegrationTestTarget({ ...mocks, prNumber: '123' }),
/found 1/,
);
});
});
describe('branch resolution', () => {
it('pins base-repository branches and their comparison base to SHAs', async () => {
const mocks = createMocks();
const result = await resolveIntegrationTestTarget({ ...mocks, branch: 'feature/test' });
assert.deepEqual(result, {
baseRef: BASE_SHA,
checkoutRef: HEAD_SHA,
description: 'branch feature/test',
});
});
});
@@ -23,16 +23,16 @@ For each project that needs to be migrated, you need to do the following:
- Identify the specific Semantic Kernel agent types being used:
- `ChatCompletionAgent``ChatClientAgent`
- `OpenAIAssistantAgent``assistantsClient.CreateAIAgent()` (via OpenAI Assistants client extension)
- `AzureAIAgent``persistentAgentsClient.CreateAIAgent()` (via Azure AI Foundry client extension)
- `AzureAIAgent``persistentAgentsClient.CreateAIAgent()` (via Microsoft Foundry client extension)
- `OpenAIResponseAgent``responsesClient.CreateAIAgent()` (via OpenAI Responses client extension)
- `A2AAgent``AIAgent` (via A2A card resolver)
- `BedrockAgent` → Custom implementation required (not supported)
- Determine if agents are being created new or retrieved from hosted services:
- **New agents**: Use `CreateAIAgent()` methods
- **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Azure AI Foundry
- **Existing hosted agents**: Use `GetAIAgent(agentId)` methods for OpenAI Assistants and Microsoft Foundry
</agent_type_identification>
- Determine the AI provider being used (OpenAI, Azure OpenAI, Azure AI Foundry, etc.)
- Determine the AI provider being used (OpenAI, Azure OpenAI, Microsoft Foundry, etc.)
- Analyze tool/function registration patterns
- Review thread management and invocation patterns
@@ -90,7 +90,7 @@ below in wrong order or skip any of them):
you generate report when migration complete. Report should contain:
- all project dependencies changes (mention what was changed, added or removed, including provider-specific packages)
- all code files that were changed (mention what was changed in the file, if it was not changed, just mention that the file was not changed)
- provider-specific migration patterns used (OpenAI, Azure OpenAI, Azure AI Foundry, A2A, ONNX, etc.)
- provider-specific migration patterns used (OpenAI, Azure OpenAI, Microsoft Foundry, A2A, ONNX, etc.)
- all cases where you could not convert the code because of unsupported features and you were unable to find a workaround
- unsupported providers that require custom implementation (Bedrock, CopilotStudio)
- breaking glass pattern migrations (InnerContent → RawRepresentation) and any CodeInterpreter or advanced tool usage
@@ -223,7 +223,7 @@ using Microsoft.Agents.AI;
// Provider-specific namespaces (add only if needed):
using OpenAI; // For OpenAI provider
using Azure.AI.OpenAI; // For Azure OpenAI provider
using Azure.AI.Agents.Persistent; // For Azure AI Foundry provider
using Azure.AI.Agents.Persistent; // For Microsoft Foundry provider
using Azure.Identity; // For Azure authentication
```
</configuration_changes>
@@ -499,7 +499,7 @@ For every thread created if there's intent to cleanup, the caller should track a
var assistantClient = new OpenAIClient(apiKey).GetAssistantClient();
await assistantClient.DeleteThreadAsync(thread.ConversationId);
// For Azure AI Foundry (when cleanup is needed):
// For Microsoft Foundry (when cleanup is needed):
var persistentClient = new PersistentAgentsClient(endpoint, credential);
await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId);
@@ -514,7 +514,7 @@ await persistentClient.Threads.DeleteThreadAsync(thread.ConversationId);
1. Remove `thread.DeleteAsync()` calls
2. Use provider-specific client for cleanup when required
3. Access thread ID via `thread.ConversationId` property
4. Only implement cleanup for providers that require it (Assistants, Azure AI Foundry)
4. Only implement cleanup for providers that require it (Assistants, Microsoft Foundry)
</api_changes>
### Provider-Specific Creation Patterns
@@ -550,13 +550,13 @@ AIAgent agent = new AzureOpenAIClient(endpoint, credential)
.CreateAIAgent(instructions: instructions);
```
**Azure AI Foundry (New):**
**Microsoft Foundry (New):**
```csharp
AIAgent agent = new PersistentAgentsClient(endpoint, credential)
.CreateAIAgent(model: deploymentName, instructions: instructions);
```
**Azure AI Foundry (Existing):**
**Microsoft Foundry (Existing):**
```csharp
AIAgent agent = await new PersistentAgentsClient(endpoint, credential)
.GetAIAgentAsync(agentId);
@@ -1079,7 +1079,7 @@ AgentThread thread = agent.GetNewThread();
```
</api_changes>
### 4. Azure AI Foundry (AzureAIAgent) Migration
### 4. Microsoft Foundry (AzureAIAgent) Migration
<configuration_changes>
**Remove Semantic Kernel Packages:**
+3 -3
View File
@@ -38,7 +38,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -140,7 +140,7 @@ jobs:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.x"
enable-cache: true
+7 -6
View File
@@ -42,7 +42,7 @@ jobs:
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
filters: |
@@ -163,6 +163,7 @@ jobs:
# Change to project directory to ensure local nuget.config is used
pushd consoleapp
dotnet add packcheck.csproj package Microsoft.Agents.AI --prerelease
dotnet add packcheck.csproj package Microsoft.Agents.AI.LocalCodeAct --prerelease
dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj
# Clean up
@@ -312,7 +313,7 @@ jobs:
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
# Azure AI Foundry
# Microsoft Foundry
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
@@ -528,7 +529,7 @@ jobs:
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
# Azure AI Foundry
# Microsoft Foundry
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
@@ -610,12 +611,12 @@ jobs:
python-version: "3.13"
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dotnet-test-results-*
path: dotnet-test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
@@ -632,7 +633,7 @@ jobs:
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: python/dotnet-integration-report-history.json
key: dotnet-integration-report-history-${{ github.run_id }}
+17 -2
View File
@@ -9,16 +9,31 @@ on:
workflow_call:
inputs:
checkout-ref:
description: "Git ref to checkout (e.g., refs/pull/123/head)"
description: "Immutable commit SHA to check out"
required: true
type: string
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
AZUREAI__ENDPOINT:
required: true
COPILOT_GITHUB_TOKEN:
required: true
OPENAI__APIKEY:
required: true
permissions:
contents: read
id-token: write
jobs:
dotnet-integration-tests:
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
+1 -1
View File
@@ -105,7 +105,7 @@ jobs:
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
# Azure AI Foundry
# Microsoft Foundry
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
+53 -52
View File
@@ -3,7 +3,7 @@
# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name.
#
# It calls dedicated integration-only workflows (dotnet-integration-tests and python-integration-tests),
# passing a ref so they check out and test the correct code.
# passing an immutable commit SHA so they check out and test the approved code.
# Changed paths are detected here so only the relevant test suites run.
#
@@ -26,7 +26,6 @@ on:
permissions:
contents: read
pull-requests: read
id-token: write
concurrency:
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
@@ -38,67 +37,50 @@ jobs:
runs-on: ubuntu-latest
outputs:
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
base-ref: ${{ steps.resolve.outputs.base-ref }}
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
python-changes: ${{ steps.detect-changes.outputs.python }}
steps:
- name: Resolve checkout ref
- name: Check out trusted workflow helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Resolve and authorize checkout ref
id: resolve
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const resolveIntegrationTestTarget = require(
'./.github/scripts/resolve_integration_test_target.js'
);
const target = await resolveIntegrationTestTarget({
github,
context,
core,
prNumber: process.env.PR_NUMBER,
branch: process.env.BRANCH,
});
core.setOutput('checkout-ref', target.checkoutRef);
core.setOutput('base-ref', target.baseRef);
core.info(`Running integration tests for ${target.description} at ${target.checkoutRef}.`);
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.inputs.pr-number }}
BRANCH: ${{ github.event.inputs.branch }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
echo "::error::Please provide either a PR number or a branch name, not both."
exit 1
fi
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
echo "::error::Please provide either a PR number or a branch name."
exit 1
fi
if [ -n "$PR_NUMBER" ]; then
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
echo "::error::Invalid PR number. Only numeric values are allowed."
exit 1
fi
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
if [ "$PR_STATE" != "OPEN" ]; then
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
exit 1
fi
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
echo "Running integration tests for PR #$PR_NUMBER"
else
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
exit 1
fi
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
echo "Running integration tests for branch $BRANCH"
fi
- name: Detect changed paths
id: detect-changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.inputs.pr-number }}
BRANCH: ${{ github.event.inputs.branch }}
BASE_REF: ${{ steps.resolve.outputs.base-ref }}
CHECKOUT_REF: ${{ steps.resolve.outputs.checkout-ref }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUMBER" ]; then
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
else
# For branches, compare against main using the GitHub API
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
fi
CHANGED_FILES=$(gh api "repos/$REPO/compare/$BASE_REF...$CHECKOUT_REF" \
--jq '.files[].filename')
DOTNET_CHANGES=false
PYTHON_CHANGES=false
@@ -113,22 +95,41 @@ jobs:
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
echo "Detected changes dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
echo "Detected changes; dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
dotnet-integration-tests:
name: .NET Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/dotnet-integration-tests.yml
with:
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
secrets: inherit
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZUREAI__ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
python-integration-tests:
name: Python Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.python-changes == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/python-integration-tests.yml
with:
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
secrets: inherit
secrets:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
+27 -7
View File
@@ -3,6 +3,12 @@ name: Issue Triage
on:
issues:
types: [opened, typed]
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
permissions:
contents: read
@@ -12,7 +18,8 @@ permissions:
concurrency:
group: >-
issue-triage-${{ github.repository }}-${{
github.event.issue.type.name == 'Bug' && github.event.issue.number
github.event_name == 'workflow_dispatch' && inputs.issue_number
|| github.event.issue.type.name == 'Bug' && github.event.issue.number
|| github.run_id
}}
cancel-in-progress: true
@@ -26,7 +33,11 @@ env:
jobs:
team_check:
runs-on: ubuntu-latest
if: ${{ github.event.issue.type.name == 'Bug' }}
if: >-
${{
github.event_name == 'workflow_dispatch'
|| github.event.issue.type.name == 'Bug'
}}
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
@@ -36,14 +47,18 @@ jobs:
id: issue
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER: >-
${{
github.event_name == 'workflow_dispatch' && inputs.issue_number
|| github.event.issue.number
}}
run: |
set -euo pipefail
issue_number="${ISSUE_NUMBER_EVENT}"
issue_number="${ISSUE_NUMBER}"
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number from event payload." >&2
echo "Could not determine issue number from event payload or manual input." >&2
exit 1
fi
@@ -58,6 +73,7 @@ jobs:
persist-credentials: false
- name: Check issue author team membership
if: ${{ github.event_name != 'workflow_dispatch' }}
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
@@ -84,7 +100,11 @@ jobs:
triage:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
if: >-
${{
github.event_name == 'workflow_dispatch'
|| needs.team_check.outputs.is_team_member == 'false'
}}
environment: integration
timeout-minutes: 60
@@ -114,7 +134,7 @@ jobs:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.x"
enable-cache: true
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
with:
path: ~/.cache/prek
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
- uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
name: Run Pre-commit Hooks (excluding poe-check)
env:
SKIP: poe-check
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version-file: "python/pyproject.toml"
enable-cache: true
+32 -6
View File
@@ -13,13 +13,27 @@ on:
workflow_call:
inputs:
checkout-ref:
description: "Git ref to checkout (e.g., refs/pull/123/head)"
description: "Immutable commit SHA to check out"
required: true
type: string
secrets:
ANTHROPIC_API_KEY:
required: true
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
COPILOT_GITHUB_TOKEN:
required: true
FOUNDRY_MODELS_API_KEY:
required: false
OPENAI__APIKEY:
required: true
permissions:
contents: read
id-token: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
@@ -99,6 +113,9 @@ jobs:
# Azure OpenAI integration tests
python-tests-azure-openai:
name: Python Integration Tests - Azure OpenAI
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -177,7 +194,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -260,6 +277,9 @@ jobs:
# Azure Functions + Durable Task integration tests
python-tests-functions:
name: Python Integration Tests - Functions
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -324,6 +344,9 @@ jobs:
# Foundry integration tests
python-tests-foundry:
name: Python Integration Tests - Foundry
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -378,6 +401,9 @@ jobs:
# Foundry Hosting integration tests
python-tests-foundry-hosting:
name: Python Integration Tests - Foundry Hosting
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
@@ -546,12 +572,12 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: python/integration-report-history.json
key: integration-report-history-integration-${{ github.run_id }}
@@ -568,7 +594,7 @@ jobs:
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: python/integration-report-history.json
key: integration-report-history-integration-${{ github.run_id }}
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
filters: |
@@ -71,7 +71,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
filters: |
@@ -298,7 +298,7 @@ jobs:
run: curl -fsSL https://ollama.com/install.sh | sh
working-directory: .
- name: Cache Ollama models
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.ollama/models
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
@@ -743,12 +743,12 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-results-*
path: test-results/
- name: Restore report history cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: python/integration-report-history.json
key: integration-report-history-merge-${{ github.run_id }}
@@ -765,7 +765,7 @@ jobs:
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save report history cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: python/integration-report-history.json
key: integration-report-history-merge-${{ github.run_id }}
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- name: Build the package
run: uv run poe --directory packages/${{ env.PACKAGE }} build
- name: Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: |
python/dist/*
@@ -815,7 +815,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Download all validation reports
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: validation-report-*
path: reports/
@@ -823,7 +823,7 @@ jobs:
- name: Restore validation history
id: cache-restore
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
@@ -841,7 +841,7 @@ jobs:
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Save validation history
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
@@ -57,7 +57,7 @@ jobs:
echo "PR_NUMBER=$ARTIFACT_PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
uses: MishaKav/pytest-coverage-comment@dd5b80bde6d16941f336518e92929e89069d8451 # v1.7.2
with:
github-token: ${{ github.token }}
issue-number: ${{ env.PR_NUMBER }}
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
+2 -2
View File
@@ -2,7 +2,7 @@
**What is Microsoft Agent Framework?**
Microsoft Agent Framework is a comprehensive multi-language (C#/.NET and Python) framework for building, orchestrating, and deploying AI agents and multi-agent workflows. The system takes user instructions and conversation inputs and produces intelligent responses through AI agents that can integrate with various LLM providers (OpenAI, Azure OpenAI, Azure AI Foundry). It provides both simple chat agents and complex multi-agent workflows with graph-based orchestration.
Microsoft Agent Framework is a comprehensive multi-language (C#/.NET and Python) framework for building, orchestrating, and deploying AI agents and multi-agent workflows. The system takes user instructions and conversation inputs and produces intelligent responses through AI agents that can integrate with various LLM providers (OpenAI, Azure OpenAI, Microsoft Foundry). It provides both simple chat agents and complex multi-agent workflows with graph-based orchestration.
**What can Microsoft Agent Framework do?**
@@ -12,7 +12,7 @@ The framework offers:
- **Multi-Agent Orchestration**: Group chat, sequential, concurrent, and handoff patterns
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, time-travel, and Human-in-the-loop
- **Extensibility Framework**: Extend with native functions, A2A, Model Context Protocol (MCP)
- **LLM Integration**: Support for OpenAI, Azure OpenAI, Azure AI Foundry, and other providers
- **LLM Integration**: Support for OpenAI, Azure OpenAI, Microsoft Foundry, and other providers
- **Runtime Support**: Both in-process and distributed agent execution
**What is/are Microsoft Agent Framework's intended use(s)?**
+2 -2
View File
@@ -2,7 +2,7 @@
# These are optional elements. Feel free to remove any of them.
status: accepted
contact: westey-m
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
date: 2025-07-10
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
consulted:
informed:
@@ -139,7 +139,7 @@ Therefore something like `AgentResponse.Text` which also aggregates all `TextCon
#### Option 1.2 Presence of Secondary Content is determined by a runtime parameter
We can allow callers to choose whether to include secondary content in the list of reponse messages.
We can allow callers to choose whether to include secondary content in the list of response messages.
Open Question: Do we allow secondary content to use `TextContent` types?
```csharp
+8 -8
View File
@@ -113,7 +113,7 @@ Implement a hybrid strategy where common tools use generic `AITool`-derived abst
### AI Agent Tool Types Availability
Tool Type | Azure AI Foundry Agent Service | OpenAI Assistant API | OpenAI ChatCompletion API | OpenAI Responses API | Amazon Bedrock Agents | Google | Anthropic | Description
Tool Type | Microsoft Foundry Agent Service | OpenAI Assistant API | OpenAI ChatCompletion API | OpenAI Responses API | Amazon Bedrock Agents | Google | Anthropic | Description
-- | -- | -- | -- | -- | -- | -- | -- | --
Function Calling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Enables custom, stateless functions to define specific agent behaviors.
Code Interpreter | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | Allows agents to execute code for tasks like data analysis or problem-solving.
@@ -132,7 +132,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### Function Calling
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/function-calling?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/function-calling?pivots=rest</a>
Message Request:
@@ -401,7 +401,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### Code Interpreter
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
<p>Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/code-interpreter-samples?pivots=rest-api">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/code-interpreter-samples?pivots=rest-api</a></p>
<p>.NET Support: ✅</p>
@@ -709,7 +709,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### Search and Retrieval
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/file-search-upload-files?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/file-search-upload-files?pivots=rest</a>
File Search Request:
@@ -1083,7 +1083,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### Web Search
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-code-samples?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-code-samples?pivots=rest</a>
Bing Search Message Request:
@@ -1630,7 +1630,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### OpenAPI Spec Tool
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/openapi-spec-samples?pivots=rest-api">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/openapi-spec-samples?pivots=rest-api</a><br>
Source: <a href="https://learn.microsoft.com/en-us/rest/api/aifoundry/aiagents/run-steps/get-run-step?view=rest-aifoundry-aiagents-v1&tabs=HTTP#runstepopenapitoolcall">https://learn.microsoft.com/en-us/rest/api/aifoundry/aiagents/run-steps/get-run-step?view=rest-aifoundry-aiagents-v1&tabs=HTTP#runstepopenapitoolcall</a>
@@ -1712,7 +1712,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### Stateful Functions
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/azure-functions-samples?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/azure-functions-samples?pivots=rest</a>
Message Request:
@@ -1832,7 +1832,7 @@ Image Generation | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | Generates or edits
#### Microsoft Fabric
<details>
<summary>Azure AI Foundry Agent Service</summary>
<summary>Microsoft Foundry Agent Service</summary>
Source: <a href="https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/fabric?pivots=rest">https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/fabric?pivots=rest</a>
Message Request:
+2 -2
View File
@@ -2,7 +2,7 @@
# These are optional elements. Feel free to remove any of them.
status: accepted
contact: westey-m
date: 2025-09-12 {YYYY-MM-DD when the decision was last updated}
date: 2025-09-12
deciders: sergeymenshykh, markwallace-microsoft, rogerbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub, peterychang
consulted:
informed:
@@ -25,7 +25,7 @@ See various features that would need to be supported via this type of mechanism,
- Also see [the openai human-in-the-loop guide](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/#approval-requests).
- Also see [the openai MCP guide](https://openai.github.io/openai-agents-js/guides/mcp/#optional-approval-flow).
- Also see [MCP Approval Requests from OpenAI](https://platform.openai.com/docs/guides/tools-remote-mcp#approvals).
- Also see [Azure AI Foundry MCP Approvals](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/model-context-protocol-samples?pivots=rest#submit-your-approval).
- Also see [Microsoft Foundry MCP Approvals](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/model-context-protocol-samples?pivots=rest#submit-your-approval).
- Also see [MCP Elicitation requests](https://modelcontextprotocol.io/specification/draft/client/elicitation)
## Decision Drivers
@@ -57,7 +57,7 @@ This section describes different options for various aspects required to add lon
### 1. Methods for Working with Long-Running Operations
Based on the analysis of existing APIs that support long-running operations (such as OpenAI Responses, Azure AI Foundry Agents, and A2A),
Based on the analysis of existing APIs that support long-running operations (such as OpenAI Responses, Microsoft Foundry Agents, and A2A),
the following operations are used for working with long-running operations:
- Common operations:
- **Start Long-Running Execution**: Initiates a long-running operation and returns its Id.
@@ -757,7 +757,7 @@ Some of them natively support resuming streaming from a specific point in the st
| API | Can Resume Streaming | Model |
|-------------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------|
| OpenAI Responses | Yes | StreamingResponseUpdate.**SequenceNumber** + GetResponseStreamingAsync(responseId, **startingAfter**, ct) |
| Azure AI Foundry Agents | Emulated<sup>2</sup> | RunStep.**Id** + custom pseudo code: client.Runs.GetRunStepsAsync(...).AllStepsAfter(**stepId**) |
| Microsoft Foundry Agents | Emulated<sup>2</sup> | RunStep.**Id** + custom pseudo code: client.Runs.GetRunStepsAsync(...).AllStepsAfter(**stepId**) |
| A2A | Implementation dependent<sup>1</sup> | |
<sup>1</sup> The [A2A specification](https://github.com/a2aproject/A2A/blob/main/docs/topics/streaming-and-async.md#1-streaming-with-server-sent-events-sse)
@@ -765,7 +765,7 @@ allows an A2A agent implementation to decide how to handle streaming resumption:
a task is still active (and the server hasn't sent a final: true event for that phase), the client can attempt to reconnect to the stream using the tasks/resubscribe RPC method.
The server's behavior regarding missed events during the disconnection period (e.g., whether it backfills or only sends new updates) is implementation-dependent._
<sup>2</sup> The Azure AI Foundry Agents API has an API to start a streaming run but does not have an API to resume streaming from a specific point in the stream.
<sup>2</sup> The Microsoft Foundry Agents API has an API to start a streaming run but does not have an API to resume streaming from a specific point in the stream.
However, it has non-streaming APIs to access already started runs, which can be used to emulate streaming resumption by accessing a run and its steps and streaming all the steps after a specific step.
#### Required Changes
@@ -828,7 +828,7 @@ Sequence of updates from OpenAI Responses API to answer the question "What time
| resp_2 | 10 | resp.output_item.done | - | InProgress | |
| resp_2 | 11 | resp.completed | Completed | Completed | |
Sequence of updates from Azure AI Foundry Agents API to answer the question "What time is it?" using a function call:
Sequence of updates from Microsoft Foundry Agents API to answer the question "What time is it?" using a function call:
| Id | SN | UpdateKind | Run.Status | Step.Status | Message.Status | ChatResponseUpdate.Status | Description |
|--------|---------|-------------------|----------------|-------------|-----------------|---------------------------|---------------------------------------------------|
| run_1 | - | RunCreated | Queued | - | - | Queued | |
@@ -852,7 +852,7 @@ Sequence of updates from Azure AI Foundry Agents API to answer the question "Wha
To support long-running operations, the following values need to be returned by the GetResponseAsync and GetStreamingResponseAsync methods:
- `ResponseId` - identifier of the long-running operation or an entity representing it, such as a task.
- `ConversationId` - identifier of the conversation or thread the long-running operation is part of. Some APIs, like Azure AI Foundry Agents, use
- `ConversationId` - identifier of the conversation or thread the long-running operation is part of. Some APIs, like Microsoft Foundry Agents, use
this identifier together with the ResponseId to identify a run.
- `SequenceNumber` - identifier of an update within a stream of updates. This is required to support streaming resumption by the GetStreamingResponseAsync method only.
- `Status` - status of the long-running operation: whether it is queued, running, failed, cancelled, completed, etc.
@@ -1089,7 +1089,7 @@ public class ChatOptions
##### 6.1.5 Continuation Token of a Custom Type
The option is similar the the "6.1.3 Continuation Token of System.ClientModel.ContinuationToken Type" option but suggests using a
The option is similar to the "6.1.3 Continuation Token of System.ClientModel.ContinuationToken Type" option but suggests using a
custom type for the continuation token instead of the `System.ClientModel.ContinuationToken` type.
**Pros**
@@ -1203,7 +1203,7 @@ response = await agent.CancelRunAsync(response.ResponseId, new AgentCancelRunOpt
In case an agent supports either or both cancellation and deletion of long-running operations, it will override the corresponding methods.
Otherwise, it won't override them, and the base implementations will return null by default.
Some agents, for example Azure AI Foundry Agents, require the thread identifier to cancel a run. To accommodate this requirement, the `CancelRunAsync` method
Some agents, for example Microsoft Foundry Agents, require the thread identifier to cancel a run. To accommodate this requirement, the `CancelRunAsync` method
accepts an optional `AgentCancelRunOptions` parameter that allows callers to specify the thread associated with the run they want to cancel.
```csharp
@@ -1574,7 +1574,7 @@ the thread is provided with background operations consistently for all runs.
</details>
<details>
<summary>Azure AI Foundry Agents</summary>
<summary>Microsoft Foundry Agents</summary>
- Create a thread and run the agent against it and wait for it to complete using polling:
```csharp
@@ -34,11 +34,11 @@ Key changes:
1. **New `agent-framework-openai` package** with dependencies on `agent-framework-core`, `openai`, and `packaging` only.
2. **Class renames**: `OpenAIResponsesClient``OpenAIChatClient` (Responses API), `OpenAIChatClient``OpenAIChatCompletionClient` (Chat Completions API). Old names remain as deprecated aliases.
3. **Deprecated classes**: `OpenAIAssistantsClient`, all `AzureOpenAI*Client` classes, `AzureAIClient`, `AzureAIAgentClient`, and `AzureAIProjectAgentProvider` are marked deprecated.
4. **New `FoundryChatClient`** in azure-ai for Azure AI Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
4. **New `FoundryChatClient`** in azure-ai for Microsoft Foundry Responses API access, built on `RawFoundryChatClient(RawOpenAIChatClient)`.
5. **All deprecated `AzureOpenAI*` classes** consolidated into a single file (`_deprecated_azure_openai.py`) in the azure-ai package for clean future deletion.
6. **Core's `agent_framework.openai` and `agent_framework.azure` namespaces** become lazy-loading gateways, preserving backward-compatible import paths while removing hard dependencies.
7. **Unified `model` parameter** replaces `model_id` (OpenAI), `deployment_name` (Azure OpenAI), and `model_deployment_name` (Azure AI) across all client constructors. The term `model` is intentionally generic: it naturally maps to an OpenAI model name *and* to an Azure OpenAI deployment name, making it straightforward to use `OpenAIChatClient` with either OpenAI or Azure OpenAI backends (via `AsyncAzureOpenAI`). Environment variables are similarly unified (e.g., `OPENAI_MODEL` instead of separate `OPENAI_CHAT_MODEL_ID` / `OPENAI_CHAT_COMPLETION_MODEL_ID`).
8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Azure AI Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
8. **`FoundryAgent`** replaces the pattern of `Agent(client=AzureAIClient(...))` for connecting to pre-configured agents in Microsoft Foundry (PromptAgents and HostedAgents). The underlying `RawFoundryAgentChatClient` is an implementation detail — most users interact only with `FoundryAgent`. `AzureAIAgentClient` is separately deprecated as it refers to the V1 Agents Service API. See below for design rationale.
### Foundry Agent Design: `FoundryAgentClient` vs `FoundryAgent`
@@ -7,11 +7,11 @@ consulted: Pratyush Mishra, Shivam Shrivastava, Manni Arora (Centrica eval scena
informed: Agent Framework team, Foundry Evals team
---
# Agent Evaluation Architecture with Azure AI Foundry Integration
# Agent Evaluation Architecture with Microsoft Foundry Integration
## Context and Problem Statement
Azure AI Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.
Microsoft Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.
However, using Foundry Evals with an agent-framework agent today requires significant manual effort. Developers must:
@@ -445,7 +445,7 @@ These factorings produce different scores for the same conversation. The framewo
### Azure AI: FoundryEvals
`Evaluator` implementation backed by Azure AI Foundry:
`Evaluator` implementation backed by Microsoft Foundry:
```python
class FoundryEvals:
@@ -812,4 +812,4 @@ public sealed class EvalItem
## More Information
- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Azure AI Foundry evaluation overview
- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Microsoft Foundry evaluation overview
@@ -9,7 +9,7 @@ deciders: evmattso
## What is the goal of this feature?
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in an Azure AI Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in a Microsoft Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK.
A user who has configured a toolbox in the Foundry portal (or via the raw SDK) should be able to load it into an agent with a single call:
+467 -87
View File
@@ -1,144 +1,524 @@
---
status: accepted
contact: eavanvalkenburg
date: 2026-06-11
date: 2026-06-30
deciders: eavanvalkenburg
consulted: rogerbarreto, moonbox3
---
# Python minimal hosting core and pluggable channels
# Python protocol helpers and optional execution state
## Context and Problem Statement
Agent Framework has several protocol-specific hosting surfaces. App authors who want one agent or workflow on multiple protocols must compose servers, routes, middleware, session handling, and lifecycle code by hand.
Agent Framework needs to help applications expose agents and workflows over external protocols such as OpenAI
Responses, Telegram, Activity Protocol, and future transports.
We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision.
FastAPI, Starlette, Azure Functions, Django, Telegram SDKs, Bot Framework SDKs, and other app frameworks already own
route registration, dependency injection, middleware, authentication, background tasks, lifecycle, and native client
calls. Agent Framework should not duplicate those surfaces unless a specific hosting environment requires it.
## Decision Drivers
- Keep the first host easy to explain: one app, one hostable target, one or more channels.
- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives.
- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces.
- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`.
- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed.
- Keep the released surface small enough to explain without first teaching a channel framework.
- Provide reusable Agent Framework run translation that works with FastAPI, Django, and other web frameworks.
- Let app/framework code own route declaration, auth, middleware, native SDK clients, command handling, and background
work.
- Keep stateful execution support explicit: session lookup/storage and workflow checkpoint lookup/storage may still need
a small AF-owned home.
## Considered Options
1. Keep only protocol-specific hosts.
2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1.
3. Ship a minimal host/channel core now and track linking/multicast as follow-up work.
1. Create protocol-specific hosts.
2. Ship a full host/channel framework with route contribution and channel hooks.
3. Ship protocol conversion helpers plus optional execution state.
### Keep only protocol-specific hosts
### 1. Create protocol-specific hosts
- Good: no new abstraction or package surface.
- Neutral: each protocol can continue evolving independently.
- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand.
- Good: no new shared abstraction.
- Neutral: each protocol host can evolve independently.
- Bad: every package reinvents AF input/result mapping, session-key conventions, and stateful execution helpers.
### Ship the large cross-channel host in v1
### 2. Ship a full host/channel framework
- Good: the richest cross-channel scenarios are available immediately.
- Neutral: the host becomes the natural place to demonstrate identity and delivery policy.
- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed.
- Good: one object can assemble routes, channels, session handling, hooks, and lifecycle callbacks.
- Good: app code using the supported host shape can be short.
- Bad: the framework owns concerns already handled by web frameworks, protocol SDKs and/or other services.
- Bad: users must understand `Channel`, contribution, hook, and host-dispatch concepts before they can see how a request
becomes `agent.run(...)`.
- Bad: the abstraction is hard to reuse outside the chosen web framework.
### Ship the minimal core now
### 3. Ship protocol helpers plus optional execution state
- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time.
- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work.
- Bad: proactive delivery and multicast scenarios are deliberately absent from v1.
- Good: protocol packages provide the Agent Framework run value directly: `<protocol>_to_run(...)` and
`<protocol>_from_run(...)` style helpers.
- Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code.
- Good: helper functions can be tested without a web framework app or host pipeline.
- Good: small state objects can still own target-coupled state: `AgentState` pairs an agent target with a `SessionStore`,
and `WorkflowState` resolves a workflow target while reusing the existing `CheckpointStorage` abstraction.
- Good: provides maximum configurability in handling input and outputs (outside of the conversions)
- Bad: building a first iteration of a new Host is more verbose.
- Bad: samples show more explicit route/client code than a fully assembled channel host.
## Decision Outcome
Chosen option: **minimal host/channel core now, follow-up enhancements later**.
Chosen option: **3. Ship protocol helpers plus optional execution state**.
`AgentFrameworkHost` owns:
Protocol packages own:
- one application object,
- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and
- one or more channels.
- parsing protocol-native input into Agent Framework run input and options;
- rendering `AgentResponse`, `AgentResponseUpdate`, workflow results, or workflow updates back into protocol-native
response/event payloads;
- protocol-specific isolation/session id helper functions when useful, such as `telegram_session_id(update)`;
- protocol-specific typing/update event helpers where the protocol has a native concept.
Channels own:
Application or web-framework code owns:
- contributed routes, middleware, commands, and lifecycle callbacks,
- protocol-native request parsing into `ChannelRequest`,
- protocol-native rendering of the originating response, and
- any channel-specific authentication or signature validation.
- HTTP route declaration and route grouping;
- dependency injection;
- authentication and authorization;
- middleware;
- background tasks and webhook acknowledgement policy;
- native protocol SDK clients and outbound calls;
- command registration and command dispatch;
- request/response status codes and framework-specific error handling;
- choosing the isolation/session id source for the current deployment and route.
The host owns:
The application builder can make the server exactly as they see fit, but this is outside the responsibilities of this proposed scheme.
This might include implementing other known API surfaces from vendors like OpenAI, such as creating conversations, vector stores, deleting things, etc.
If they want they can build the full OpenAI API, but it will include code that does not rely on agent-framework-hosting, which is fine.
They are responsible for what they expose.
- route/lifecycle aggregation,
- invocation of the target,
- `ChannelSession(isolation_key=...)` to `AgentSession` resolution and caching,
- `reset_session(isolation_key=...)`,
- host-level middleware, including Foundry isolation middleware only when the Foundry hosting environment flag is present,
- invocation of per-channel hooks (`ChannelRunHook`, `ChannelResponseHook`, `ChannelStreamUpdateHook`), and
- workflow checkpoint wiring through an explicit `checkpoint_location`.
The optional execution-state helpers, if provided, are limited to shared execution state:
`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key.
- `AgentState`: one `SupportsAgentRun`-compatible target plus a `SessionStore`;
- `WorkflowState`: one `Workflow`, `WorkflowBuilder`-shaped builder, orchestration builder, or workflow factory;
- `SessionStore`: plain async storage (`get` / `set` / `delete`) by an app-selected id.
### Trust boundary for `isolation_key`
The store does not create sessions. `AgentState` provides the target-aware `get_or_create_session(...)` helper because
only the state object has both the store and the resolved agent target. Workflow checkpointing should use the existing
`CheckpointStorage` abstraction directly; app/state code may keep a small cursor (`session_id -> checkpoint_id`) when it
needs to resume a workflow for a session.
The host treats `ChannelSession.isolation_key` as a session partition key, not as proof of identity. Channels or host middleware must authenticate and authorize any externally supplied value before passing it to the host. For example, a Responses caller must not be allowed to choose an arbitrary `previous_response_id` or header-derived key unless the platform or middleware has already established that the caller owns that conversation. The host deliberately does not infer that trust from the string itself.
These objects are **not** app objects, channel registries, or route owners. They do not own FastAPI/Starlette setup,
route contribution, protocol dispatch, command projection, or native SDK calls.
### Hook ownership
### Helper naming and families
Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline:
Helpers should be protocol-specific, not generic. Avoid a generic `protocol_to_run(...)` name in public samples because it
hides the protocol-specific contract behind a second abstraction.
- `ChannelRunHook` runs after channel parsing and before target invocation.
- `ChannelResponseHook` runs after target invocation and before the originating channel serializes its response.
- `ChannelStreamUpdateHook` is applied by the host while the channel consumes streamed updates because streaming serialization is protocol-specific.
Protocol packages should consider these helper families. This table is a set of examples, not a required protocol or
checklist. Not every protocol needs every helper, but when a protocol has the concept the naming should stay consistent:
`ChannelStreamUpdateHook` is an update hook, not a final-response sanitizer. Channels that use it for redaction or filtering must also apply equivalent policy to any final response they render. Channels choose whether the response is streaming before run hooks execute.
| Helper family | Shape | Purpose |
| --- | --- | --- |
| Run conversion | `<protocol>_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. |
| Final rendering | `<protocol>_from_run(...)` | Convert a final `AgentResponse` / workflow result into protocol-native response payloads or operations. |
| Stream rendering | `<protocol>_from_streaming_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. |
| Session id extraction | `<protocol>_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. |
| Command/action parsing | `<protocol>_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. |
This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages.
Examples:
### State owned by v1
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
`responses_session_id(...)`;
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
`telegram_session_id(...)`, `telegram_command(...)`;
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`.
`state_dir` is limited to host-owned local files for reset-session aliases and workflow checkpoint path derivation. It does not store linked identities, active-channel state, response-routing state, continuation records, durable runner queues, or delivery attempts. Those storage concerns belong to ADR-0028.
The app still owns what a parsed command means. For example, a Telegram `/new`, Discord slash command, Bot Framework
command activity, or A2A cancellation/request action may parse through a command/action helper, but the route or SDK
handler decides whether that command clears a session, cancels a task, calls an agent, or is ignored.
Additional helper functions can be protocol-specific when the concept is not broadly shared. Examples include
`telegram_chat_id(...)`, `telegram_callback_query_id(...)`, `telegram_media_file_id(...)`,
`discord_interaction_id(...)`, `a2a_task_id(...)`, `a2a_context_id(...)`, and MCP tool/prompt/resource helpers. These
helpers should still stay side-effect-free: they extract, normalize, or describe protocol data, while app/native SDK code
performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers
handlers.
### Security responsibilities for application builders
The application builder owns the trust boundary. Protocol helper packages can parse native payloads and expose candidate
ids or operations, but they do not authenticate callers, authorize access to state, or decide which side effects are
allowed.
Application code that uses these helpers are responsible for (this means that we advice you to think through these topics,
but ultimately, the choice of which controls are needed for the intended use case is up to the application builder):
- authenticate the caller through the app's normal mechanism before using protocol-provided ids;
- authorize any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading
state for it;
- bind externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before using
them as `SessionStore` keys or checkpoint cursor keys;
- treat `<protocol>_session_id(...)` results as untrusted candidate keys until that ownership check has passed;
- keep platform-provided isolation helpers fail-closed outside their trusted hosting environment;
- authorize command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them;
- opt in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider;
- persist post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization has
updated that state.
For Foundry specifically, helpers may read values established by Foundry hosting middleware, but must not treat raw
request headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that
non-Foundry requests do not accept spoofable isolation headers as platform-provided keys.
For workflow checkpointing, the checkpoint boundary must be at least as specific as the authorized session/tenant
boundary. A shared storage lookup such as "latest checkpoint for workflow name" is safe only when the storage is already
scoped to the authorized session. In a shared durable store, map the authorized `session_id` to a checkpoint id or other
cursor and load that specific checkpoint.
### Session continuity
Session continuity remains explicit. Run parsing and isolation/session id selection are separate operations because
isolation can come from more than one source:
- protocol input, such as OpenAI Responses `previous_response_id`, a Telegram chat id, or an Activity conversation id;
- running environment, such as Foundry Hosted Agents user/chat isolation context;
- app-specific trusted middleware or route state.
The app chooses which helper to call for that route and deployment. For example:
- `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous
response id or a `conv_*` conversation id when present;
- `telegram_session_id(update, bot_id=...)` from `agent-framework-hosting-telegram`, which uses the bot and sender for
private chats and the bot and chat for shared group sessions;
- `activity_session_id(activity)`, `discord_session_id(interaction_or_message)`, or
`a2a_session_id(request_context)` from their respective protocol packages;
- `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`.
Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the
trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key.
The application builder is also responsible for deciding whether the hosting environment is **persistent** (for example,
a long-running container or web app) or **transient** (for example, Azure Functions, Foundry Hosted Agents, or any
environment where process memory is not a reliable continuity boundary). That decision controls which state mechanisms are
safe to use:
- persistent single-process apps may use in-memory state for local development or simple deployments, while still needing
durable state for multi-replica continuity;
- transient apps must not rely on in-memory `SessionStore` state between calls and need a durable session store or a
service-owned continuation id;
- workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable
`session_id -> checkpoint_id` cursor because in-process workflow state and in-memory checkpoint cursors do not survive
transient execution.
A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent
target and creates the session on first use. Reads return independent working copies so running from one continuation
point does not mutate the stored snapshot or another simultaneous branch:
For agent targets:
```python
session = await state.get_or_create_session(session_id)
target = await state.get_target()
result = await target.run(messages, session=session, options=options)
```
If the protocol mints a new continuation id as part of the response being created (for example, OpenAI Responses
`resp_*` ids), store the **post-run** session explicitly under that new id:
```python
session = await state.get_or_create_session(previous_response_id)
target = await state.get_target()
result = await target.run(messages, session=session, options=options)
await state.set_session(response_id, session)
```
`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call
belongs after the run, not before it.
Response ids are immutable continuation points, so simultaneous callers can branch from one `previous_response_id` and
store their completed sessions under different new response ids. A stable `conversation_id` is a mutable head: the app
must explicitly update it after the run and provide single-writer coordination. The hosting state helper does not lock
an entire run or resolve concurrent updates to that stable key.
The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any
externally supplied key before using it.
### Workflow checkpoints
Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with checkpoint
state, but it should not wrap or replace the existing `CheckpointStorage` abstraction. Apps should pass the actual
`CheckpointStorage` they want the workflow to use. If an app needs per-session resume, it can keep a small cursor from
authorized `session_id` to `checkpoint_id` (or an equivalent store-specific resume token).
Workflow runs do not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default. The
runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. App/state code that owns the storage can
observe the latest id by querying the storage after a run, for example
`await storage.get_latest(workflow_name=target.name)`.
For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the
workflow through the state object's target:
```python
# session_id must already be authenticated and authorized for this caller
target = await state.get_target()
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
if latest is not None:
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
```
If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to
`workflow.run(...)`:
```python
# session_id must already be authenticated and authorized for this caller
target = await state.get_target()
checkpoint_id = await checkpoint_cursor_store.get(session_id)
if checkpoint_id is None:
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
else:
result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage)
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
if latest is not None:
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
```
`workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer.
Protocol helper packages should not own checkpoint layout, route lifecycle, or durable execution.
## Non-goals for v1
The following are deliberately **not** part of the v1 contract:
The following remain outside the v1 protocol-helper contract. Some are deliberately app-owned in v1; others are possible
future framework work only after a separate design.
- cross-channel identity linking (`IdentityLinker`, `local_identity_link`, or `agent-framework-hosting-entra`),
- identity allowlists or authorization policy (`IdentityAllowlist`, `AuthPolicy`),
- response routing beyond the originating channel (`ResponseTarget`, active channel, specific linked channel, `all_linked`),
- push or payload codecs (`ChannelPush`, `ChannelPushCodec`),
- background/continuation delivery,
- durable task runners (`DurableTaskRunner`, `InProcessTaskRunner`),
- retry/replay policy (`RetryPolicy`),
- fan-out, multicast, or all-linked delivery,
- confidentiality tiers and `LinkPolicy`, and
- a host-level multi-agent router.
### App-owned in v1
These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 host.
The app builder owns these concerns with normal web-framework, SDK, platform, or application code:
- authentication, authorization policy, and allowlists;
- deciding whether identities across protocols map to the same `session_id`;
- non-originating sends using native SDK clients;
- background work, durable execution, retry, and replay when app code owns the work;
- routing between multiple agents.
This is easier in the protocol-helper model than it was in the host/channel model: app code already owns the native SDK
clients, route handlers, authenticated caller context, session id selection, and outbound send calls. An app can link
channels by choosing the same authorized `session_id` for multiple protocols, and can do non-originating delivery by
calling the destination protocol's native client directly. That does not make a reusable framework feature safe by
default; it just means the app-specific version no longer has to fight a host abstraction.
### Future framework work
The following require a reviewed identity, storage, delivery, replay, and observability model before becoming reusable
framework features:
- reusable cross-channel identity linking;
- framework-owned proactive or non-originating delivery;
- fan-out, multicast, selected-channel, active-channel, or all-linked delivery;
- framework-owned delivery observability, dead-letter handling, and replay semantics;
- cross-channel confidentiality and link policy.
These possible framework enhancements are tracked by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are
not prerequisites for shipping or using the v1 protocol-helper surface. ADR-0028 was written against the earlier
host/channel framing and must be revised to align with this protocol-helper and execution-state boundary before those
enhancements are implemented.
## Consequences
Positive:
- The host/channel model can be implemented and tested without designing a security-sensitive identity graph.
- Existing and new channel packages can share one Starlette app, middleware stack, lifecycle, and target invocation path.
- Session continuity is explicit and debuggable: two channels share history only when they produce the same `isolation_key`.
- Hook invocation is centralized in the host, so channels do not each invent the call convention.
- The released surface is smaller and easier to inspect: helpers plus state, not a channel framework.
- Protocol helpers can be used from FastAPI, Starlette, Azure Functions, Django, CLI tools, tests, or native SDK webhook
handlers.
- App authors can use the authentication, dependency injection, lifecycle, and background-task tools they already know.
- Session continuity stays explicit and debuggable.
- Workflow checkpointing can still be centralized if needed without making protocol packages own routing.
Negative:
- Apps that need OAuth linking, allowlists, proactive messages, or multicast must continue to implement those behaviors outside the v1 host.
- Some richer cross-channel scenarios from the original design move to a separate decision and validation cycle.
- The host must document `isolation_key` trust clearly because it now provides the shared session boundary.
## Validation Gates
Before this ADR is accepted:
- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition.
- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host.
- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping.
- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path.
- Workflow tests or samples use an explicit `checkpoint_location`.
- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored.
- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1).
- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs.
- Multi-protocol samples include explicit route/client code.
- Apps that want a batteries-included ASGI app must write or depend on an app-specific wrapper.
- Existing unreleased code and docs that mention channels, contribution, or hooks must be revised before release.
## More Information
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md)
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md). That ADR still uses
some earlier host/channel terminology and must be aligned before implementation work starts.
## Appendix: Developer experience sketch
The examples below are sketches, not runtime-ready sample code. They show the minimum shape a developer would need to
build: where protocol helpers are called, where app-owned auth/authorization belongs, where state is loaded/stored, and
where native framework code remains in charge.
### Optional execution state
`AgentState` and `WorkflowState` stay small: they are target-specific state holders, not app hosts.
```python
from typing import Protocol
from agent_framework import AgentSession, SupportsAgentRun, Workflow
class SupportsBuild(Protocol):
def build(self) -> Workflow: ...
class SessionStore:
async def get(self, session_id: str) -> AgentSession | None: ...
async def set(self, session_id: str, session: AgentSession) -> None: ...
async def delete(self, session_id: str) -> None: ...
class CheckpointCursorStore:
async def get(self, session_id: str) -> str | None: ...
async def set(self, session_id: str, checkpoint_id: str) -> None: ...
async def delete(self, session_id: str) -> None: ...
class AgentState:
def __init__(self, target: SupportsAgentRun, *, session_store: SessionStore | None = None) -> None: ...
async def get_target(self) -> SupportsAgentRun: ...
async def get_or_create_session(self, session_id: str) -> AgentSession: ...
async def set_session(self, session_id: str, session: AgentSession) -> None: ...
class WorkflowState:
def __init__(self, target: Workflow | SupportsBuild) -> None: ...
async def get_target(self) -> Workflow: ...
```
`WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with
`build() -> Workflow`. That structurally covers `WorkflowBuilder` and the builders in `agent_framework_orchestrations`
without making `agent-framework-hosting` depend on the orchestration package.
### Responses-only route
This sketch shows the intended Responses-only shape. The protocol package owns the Agent Framework run conversion helpers and
response-id minting details; the application owns FastAPI routing, auth, policy adjustment, and response construction.
```python
import os
from collections.abc import AsyncIterator
from agent_framework import Agent, ResponseStream
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue]
from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_from_streaming_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue]
from fastapi import Body, FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
app = FastAPI()
agent = Agent(
client=OpenAIChatClient(),
name="Assistant",
instructions="Be concise and helpful.",
)
state = AgentState(agent)
@app.post("/responses")
async def responses(body: dict = Body(...), x_api_key: str | None = Header(default=None)) -> JSONResponse | StreamingResponse:
if x_api_key != os.environ["RESPONSES_API_KEY"]:
raise HTTPException(status_code=401, detail="bad api key")
# parse the request body into a set of AF objects
run = responses_to_run(body)
# get the candidate session id from the body
# can be a resp_* for previous_response_id or a conv_* for a conversation
candidate_session_id = responses_session_id(body)
# create a new response_id for this run
response_id = create_response_id()
# the developer can make any adjustments to the request, i.e.:
run["options"]["store"] = False
run["options"].pop("model", None)
# the options here are of the shape defined by the ChatClient/Agent
# load the session (or create a new one) - this is optional
# verify this caller owns candidate_session_id before loading it; API-key auth
# alone does not prove ownership of a caller-supplied resp_* or conv_* id
session_id = candidate_session_id or response_id
session = await state.get_or_create_session(session_id)
target = await state.get_target()
if run["stream"]:
stream = target.run(
run["messages"],
stream=True,
session=session,
options=run["options"],
)
async def stream_events() -> AsyncIterator[str]:
async for event in responses_from_streaming_run(
stream,
response_id=response_id,
session_id=candidate_session_id,
):
yield event
# agent.run may update the session during stream finalization, so store the post-run session explicitly
await state.set_session(response_id, session)
return StreamingResponse(stream_events(), media_type="text/event-stream")
result = await target.run(
run["messages"],
session=session,
options=run["options"],
)
# agent.run may update the session, so store the post-run session explicitly under the response id
# this might also be skipped, if the app chooses to respect `store=False` policy
await state.set_session(response_id, session)
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
```
### Responses-only Django class-based view
The same helper surface can be used without FastAPI. A Django app owns URL routing, CSRF/auth policy, request parsing,
and `JsonResponse` construction. In a real Django project this would live in the app's normal view module (for example
`assistant/views.py`) and be routed from that app's `urls.py`; Django discovers it through its standard project/app
layout, not through Agent Framework. This sketch shows the non-streaming path only; the streaming branch is the same
state/finalization pattern shown in the FastAPI sketch and is omitted here to avoid duplicating it.
```python
import json
import os
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue]
from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue]
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse
from django.views import View
agent = Agent(
client=OpenAIChatClient(),
name="Assistant",
instructions="Be concise and helpful.",
)
state = AgentState(agent)
class ResponsesView(View):
async def post(self, request: HttpRequest) -> JsonResponse:
if request.headers.get("x-api-key") != os.environ["RESPONSES_API_KEY"]:
return HttpResponseForbidden("bad api key")
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponseBadRequest("invalid json")
run = responses_to_run(body)
candidate_session_id = responses_session_id(body)
response_id = create_response_id()
options = run["options"]
# verify this caller owns candidate_session_id before loading it; API-key auth
# alone does not prove ownership of a caller-supplied resp_* or conv_* id
session_id = candidate_session_id or response_id
session = await state.get_or_create_session(session_id)
target = await state.get_target()
result = await target.run(
run["messages"],
session=session,
options=options,
)
await state.set_session(response_id, session)
return JsonResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
```
+381 -276
View File
@@ -1,320 +1,425 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-06-11
date: 2026-07-08
deciders: eavanvalkenburg
---
# Python hosting core and pluggable channels
# Python protocol helpers and optional execution state
## Scope
This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only.
This specification is the Python implementation plan for
[ADR-0027](../decisions/0027-hosting-channels.md). It documents the helper-first v1 contract for Python hosting.
The v1 contract is:
- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels.
- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`.
- Channels contribute routes, middleware, commands, and lifecycle callbacks.
- Channels parse protocol-native input into `ChannelRequest`.
- Channels render their own originating response.
- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key.
- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context.
The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md).
- protocol packages expose helper functions that convert protocol-native input to Agent Framework run values;
- protocol packages expose helper functions that convert Agent Framework run results or streams back to protocol-native
payloads or operations;
- application/framework code owns routes, native SDK clients, authentication, command policy, webhooks, response status
codes, and outbound sends;
- `agent-framework-hosting` provides small optional state holders for Agent Framework targets;
- state helpers do not own web apps, route contribution, protocol dispatch, command projection, or native SDK calls.
## Goals
- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition.
- Keep protocol parsing and response formatting inside channel packages.
- Provide one session-resolution path shared by all channels.
- Keep the channel authoring surface small enough for new channels to implement.
- Preserve full-fidelity agent and workflow results until a channel decides how to render them.
- Let apps expose agents and workflows from FastAPI, Starlette, Django, Azure Functions, native SDK webhooks, CLIs, and
tests without adopting a host/channel framework.
- Keep protocol parsing and response formatting inside protocol packages.
- Keep session continuity explicit and app-owned at the trust boundary.
- Reuse Agent Framework primitives: `AgentSession`, `CheckpointStorage`, `Agent.run(...)`, `Workflow.run(...)`, and
`ResponseStream`.
- Preserve full-fidelity Agent Framework results until a protocol helper renders them.
## Non-goals for v1
The following are removed from the v1 implementation pass:
### App-owned in v1
- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy`
- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast
- `ChannelPush` and `ChannelPushCodec`
- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy`
- continuation tokens and background delivery
- confidentiality tiers
- `agent-framework-hosting-entra`
- `local_identity_link`
The app builder owns these concerns with normal web-framework, SDK, platform, or application code:
These are follow-up design topics, not hidden requirements of the v1 host.
- authentication, authorization policy, and allowlists;
- deciding whether identities across protocols map to the same `session_id`;
- non-originating sends using native SDK clients;
- background work, durable execution, retry, and replay when app code owns the work;
- routing between multiple agents.
The helper-first model makes app-owned linking and non-originating delivery easier than the old host/channel model because
app code already owns the native SDK clients, authenticated caller context, session id selection, and outbound sends.
### Future framework work
The following require a separate reviewed design before becoming reusable framework features:
- reusable cross-channel identity linking;
- framework-owned proactive or non-originating delivery;
- fan-out, multicast, selected-channel, active-channel, or all-linked delivery;
- framework-owned delivery observability, dead-letter handling, and replay semantics;
- cross-channel confidentiality and link policy.
[ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) tracks possible follow-up work in this area and
must be aligned with the helper-first model before implementation. Old vocabulary such as `IdentityLinker`,
`ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `RetryPolicy`, and `LinkPolicy` is not v1 API.
## Packages
| Package | Import surface | Contents |
| Package | Import surface | v1 helper-first contents |
|---|---|---|
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. |
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. |
| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. |
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. |
| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. |
| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. |
| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. |
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. |
| `agent-framework-hosting-a2a` | `agent_framework_hosting_a2a` | A2A `Message` to run conversion and Agent Framework output to A2A `Part` conversion. |
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. |
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | Telegram Bot API helpers: update parsing, chat/session/command/media extraction, final rendering, and streaming edit rendering. |
| Future protocol packages | e.g. `agent_framework_hosting_activity_protocol` | Protocol-specific helpers such as `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, and command/media helpers when useful. |
Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts.
The core hosting package must not depend on protocol SDKs. Protocol packages may depend on their native protocol SDKs if
needed, but helper functions should stay usable from plain app code and tests.
## Key Types
## Helper naming and families
### `AgentFrameworkHost`
Helper names are protocol-specific. Avoid a generic `protocol_to_run(...)` public surface.
The host constructor accepts:
Protocol packages may provide the following helper families when the protocol has the concept:
- `target`: one `SupportsAgentRun`-compatible object or one `Workflow`
- `channels`: one or more `Channel` instances
- optional Starlette middleware
- optional `state_dir`
- optional workflow `checkpoint_location`
| Helper family | Shape | Purpose |
| --- | --- | --- |
| Run conversion | `<protocol>_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. |
| Final rendering | `<protocol>_from_run(...)` | Convert a final `AgentResponse` or workflow result into protocol-native response payloads or operations. |
| Stream rendering | `<protocol>_from_streaming_run(...)` | Convert `ResponseStream` or workflow updates into protocol-native events or operations. |
| Session id extraction | `<protocol>_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. |
| Command/action parsing | `<protocol>_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. |
The host exposes:
Examples:
- `app`: the canonical Starlette ASGI application
- `serve(...)`: a convenience wrapper for local serving
- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
`responses_session_id(...)`;
- `a2a_to_run(...)`, `a2a_from_run(...)`;
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
`telegram_session_id(...)`, `telegram_command(...)`;
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`.
`state_dir` is narrowed to v1 host-owned local files only:
This table is a naming guide, not a required checklist. A protocol package should add only the helpers that match native
protocol concepts and current samples.
- session aliases (`isolation_key` to current `AgentSession` id), and
- workflow checkpoint paths when the app chooses the host-provided file layout.
Protocol-specific helpers may also exist for native details such as `telegram_chat_id(...)`,
`telegram_callback_query_id(...)`, `telegram_media_file_id(...)`, `discord_interaction_id(...)`, `a2a_task_id(...)`,
`a2a_context_id(...)`, or MCP tool/prompt/resource helpers. These helpers should stay side-effect-free. App/native SDK
code performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers
handlers.
It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads.
## `agent-framework-hosting` state helpers
Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership.
### `SessionStore`
### `Channel`
A channel implements a small protocol:
- declare a stable channel id/name,
- contribute routes, middleware, commands, and lifecycle callbacks,
- parse inbound protocol data into `ChannelRequest`,
- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and
- serialize the returned result to the originating protocol response.
Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies.
### `ChannelContribution`
`ChannelContribution` is the channel's host-facing contribution:
- Starlette routes and optional middleware,
- native command descriptors,
- startup and shutdown callbacks, and
- any channel-local metadata needed by the package.
The host aggregates contributions but does not interpret protocol payloads.
### `ChannelRequest`
`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries:
- target input,
- optional `ChannelSession`,
- optional `ChannelIdentity`,
- options and attributes produced by the channel, and
- request metadata useful to hooks and context providers.
The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract.
### `ChannelSession`
`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism.
When a request contains an isolation key:
1. The host looks up or creates the cached `AgentSession` for that key.
2. The target runs with that `AgentSession` when the target is an agent.
3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation.
If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state.
### `ChannelIdentity`
`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes.
In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`.
### Hooks
Hooks are optional and channel-owned:
- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute.
- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response.
- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream.
Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks.
### `HostedRunResult`
`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`.
- Agent targets produce `HostedRunResult[AgentResponse]`.
- Workflow targets produce `HostedRunResult[WorkflowRunResult]`.
The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry.
## Host Behavior
1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution.
2. A channel route receives a protocol-native request.
3. The channel validates/parses the native payload and creates `ChannelRequest`.
4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host.
5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request.
6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present.
7. The host invokes the agent or workflow target.
8. The host wraps the result in `HostedRunResult` or the streaming equivalent.
9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping.
10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response.
There is no host-level route from one channel's request to another channel's response in v1.
## Workflow Checkpoints
Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location.
`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state.
## Foundry Isolation Middleware
V1 keeps Foundry isolation as middleware rather than as a channel-linking feature.
The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware.
This middleware does not create cross-channel identity links and does not authorize non-Foundry channels.
## Current Channels
### Responses
`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses.
Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata.
### Invocations
`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response.
Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type.
### Telegram
`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat.
The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key.
### Activity Protocol
`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces.
The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics.
### Discord
`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input.
The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path.
## High-level Samples
### One agent on Responses
`SessionStore` is an in-memory async lookup:
```python
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel()],
class SessionStore:
async def get(self, session_id: str) -> AgentSession | None: ...
async def set(self, session_id: str, session: AgentSession) -> None: ...
async def delete(self, session_id: str) -> None: ...
```
The store does not create sessions. It stores `session_id -> AgentSession` values supplied by callers.
The built-in store has no TTL or eviction. This is intentional for local/dev and simple process-local scenarios: protocols
such as OpenAI Responses can continue from any prior response id. Durable or multi-replica deployments should provide a
durable store and their own TTL/eviction policy.
### `AgentState`
`AgentState` holds an agent target and an optional `SessionStore`:
```python
state = AgentState(agent)
state = AgentState(create_agent)
state = AgentState(create_agent, cache_target=False)
```
The target may be:
- a `SupportsAgentRun` instance;
- a synchronous factory;
- an asynchronous factory;
- an awaitable target.
`AgentState` provides:
- `await get_target()`;
- synchronous `target` only after a target is already available/resolved;
- `session_store`;
- `await get_or_create_session(session_id)`;
- `await set_session(session_id, session)`.
`get_or_create_session(...)` resolves the target and calls `target.create_session(session_id=...)` only when the store has
no session for that id.
Apps must store the post-run session explicitly after `agent.run(...)` or stream finalization:
```python
session = await state.get_or_create_session(session_id)
target = await state.get_target()
result = await target.run(messages, session=session, options=options)
await state.set_session(response_id, session)
```
### `WorkflowState`
`WorkflowState` resolves a workflow target. It does not own checkpoint storage.
The target may be:
- a `Workflow` instance;
- a `WorkflowBuilder` or other object with `build() -> Workflow`;
- a synchronous factory;
- an asynchronous factory;
- an awaitable target.
`WorkflowState` provides:
- `await get_target()`;
- synchronous `target` only after a target is already available/resolved.
A workflow instance permits one active run. Concurrent hosts use a factory or
builder with `cache_target=False` to resolve a fresh instance per run.
Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need
per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses
file-backed cursor storage, the file-based checkpoint storage should share the same app storage root and should be
scoped to the current authenticated user/tenant/session bucket, for example
`storage/checkpoints/<session-bucket>/` beside `storage/checkpoint_cursors.json`:
```python
# session_id must already be authenticated and authorized for this caller
target = await workflow_state.get_target()
checkpoint_id = await checkpoint_cursor_store.get(session_id)
if checkpoint_id is None:
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
else:
result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage)
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
if latest is not None:
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
```
`Workflow.run(...)` does not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default.
The runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. Apps that own the storage can query
`get_latest(workflow_name=...)` after the run if they need to update a cursor.
## `agent-framework-hosting-responses`
The Responses package provides the helper-first surface for OpenAI Responses-shaped requests.
### Request helpers
- `messages_from_responses_input(input) -> list[Message]`
- `responses_to_run(body) -> AgentRunArgs`
- `responses_session_id(body) -> str | None`
- `create_response_id() -> str`
`responses_to_run(...)` returns values corresponding to `Agent.run(...)`:
```python
run = responses_to_run(body)
messages = run["messages"]
options = run["options"]
stream = run["stream"]
```
It excludes protocol transport/session fields from `options` and remaps known Responses option names such as
`max_output_tokens -> max_tokens`.
`responses_session_id(...)` returns:
- `previous_response_id` when present (`resp_*`);
- otherwise `conversation_id` when present (`conv_*`);
- otherwise `None`.
The helper only extracts the candidate key. App code decides whether to trust and use that key.
### Response helpers
- `responses_from_run(result, *, response_id, session_id=None) -> dict[str, Any]`
- `responses_from_streaming_run(stream, *, response_id, session_id=None) -> AsyncIterator[str]`
`responses_from_run(...)` renders a full Responses JSON payload from an `AgentResponse`. It renders the full set of
OpenAI Responses output item types supported by Agent Framework content.
`responses_from_streaming_run(...)` renders Server-Sent Event strings for a `ResponseStream`. It emits a created event,
text deltas, and a completed event. The final completed payload is produced through `responses_from_run(...)`; the helper
also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model
metadata.
## `agent-framework-hosting-a2a`
The A2A package provides only the conversion seam between the native A2A SDK
and Agent Framework:
- `a2a_to_run(message, *, stream=False) -> AgentRunArgs`
- `a2a_from_run(result) -> list[a2a.types.Part]`
`a2a_to_run(...)` accepts a native A2A `Message` and converts its text, URL,
raw-byte, and structured-data parts into one Agent Framework user message.
`a2a_from_run(...)` accepts an `AgentResponse`, `Message`, or
`AgentResponseUpdate` and converts supported text, URI, and data content into
native A2A `Part` values. This one helper is usable for both completed and
streaming runs.
The package does not provide an A2A `AgentExecutor`, application, route,
request handler, task store, event queue, `TaskUpdater`, task-state policy,
artifact-id policy, or session-key policy. Application code composes the two
helpers with those native A2A SDK constructs and may use any server framework
supported by the SDK.
## `agent-framework-hosting-telegram`
The Telegram package provides side-effect-free helpers around Telegram Bot API
update and method payloads. It does not provide a Bot API client, polling loop,
webhook route, command registry, retry policy, or rate limiter.
### Update helpers
- `telegram_to_run(update, *, resolve_file_url=None, stream=False) -> AgentRunArgs`
- `telegram_chat_id(update) -> int | None`
- `telegram_session_id(update, *, bot_id) -> str | None`
- `telegram_command(update) -> str | None`
- `telegram_callback_query_id(update) -> str | None`
- `telegram_media_file_id(update_or_message) -> tuple[str, str] | None`
`telegram_to_run(...)` handles `message`, `edited_message`, and
`callback_query` updates. Text and captions become AF text content. When the
app supplies an async `resolve_file_url` callback, supported Telegram media
file ids can become AF URI content. The package does not call Telegram's
`getFile` method itself.
`telegram_session_id(..., bot_id=...)` includes the bot identity in every key.
Private chats return `telegram:<bot_id>:<user_id>`; other chats return
`telegram:<bot_id>:<chat_id>`, giving groups a shared session by default. This
matches Telegram's native isolation boundaries while preventing two bots from
sharing state accidentally. Apps that want per-user sessions inside a group
can construct a key that includes both chat and sender ids. The app must
authorize those Telegram identities before loading session state.
`telegram_command(...)` parses Telegram's `/name` and `/name@bot` syntax. It
does not register commands or invoke handlers.
### Response helpers
- `telegram_from_run(result, *, chat_id, parse_mode=None)`
- `telegram_from_streaming_run(stream, *, chat_id, message_id, initial_text=None, parse_mode=None)`
The helpers produce Telegram method/payload values for app-owned Bot API
calls. Final rendering supports text and image URI output and applies
Telegram's text-length boundary. Streaming rendering produces cumulative
`editMessageText` payloads for a placeholder message id supplied by the app,
omitting edits that match an optional `initial_text`, then renders the final
rich output. Image-only responses remove the placeholder with `deleteMessage`
before sending the image. The app owns the initial placeholder send, Bot API
calls, edit throttling, retries, and failure policy.
## Security responsibilities
Protocol helper packages parse and render. They do not authenticate callers, authorize access to state, or decide which
side effects are allowed.
Application code that uses these helpers is responsible for:
- authenticating the caller through the app's normal mechanism before using protocol-provided ids;
- authorizing any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading
state for it;
- binding externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before
using them as `SessionStore` keys or checkpoint cursor keys;
- treating `<protocol>_session_id(...)` results as untrusted candidate keys until that ownership check has passed;
- keeping platform-provided isolation helpers fail-closed outside their trusted hosting environment;
- authorizing command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them;
- opting in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider;
- persisting post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization
has updated that state.
## Persistent versus transient hosting
The application builder decides whether the server is persistent or transient.
- Persistent single-process apps, such as a long-running container or web app, may use in-memory state for local
development or simple deployments. Multi-replica persistent apps still need durable state for continuity.
- Transient apps, such as Azure Functions, Foundry Hosted Agents, or any environment where process memory is not a
reliable boundary, must not rely on in-memory `SessionStore` state between calls. They need a durable session store or
a service-owned continuation id.
- Workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable
`session_id -> checkpoint_id` cursor. File-backed checkpoint storage and file-backed cursor storage should live under
the same app storage root, with checkpoints scoped to the current authenticated user/tenant/session bucket so a
"latest checkpoint" lookup cannot cross conversations. In-process workflow state and in-memory checkpoint cursors do
not survive transient execution.
## Minimal FastAPI Responses shape
This is the shape the local Responses sample should demonstrate. It is not an app framework.
```python
from collections.abc import AsyncIterator
from agent_framework import ResponseStream
from agent_framework_hosting import AgentState
from agent_framework_hosting_responses import (
create_response_id,
responses_from_run,
responses_from_streaming_run,
responses_session_id,
responses_to_run,
)
from fastapi import Body, FastAPI, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
app = host.app
app = FastAPI()
state = AgentState(create_agent)
@app.post("/responses", response_model=None)
async def responses(body: dict = Body(...)) -> JSONResponse | StreamingResponse:
run = responses_to_run(body)
candidate_session_id = responses_session_id(body)
response_id = create_response_id()
# Verify this caller owns candidate_session_id before loading it.
session_id = candidate_session_id or response_id
session = await state.get_or_create_session(session_id)
target = await state.get_target()
if run["stream"]:
stream = target.run(run["messages"], stream=True, session=session, options=run["options"])
if not isinstance(stream, ResponseStream):
raise HTTPException(status_code=500, detail="agent did not return a response stream")
async def events() -> AsyncIterator[str]:
async for event in responses_from_streaming_run(
stream,
response_id=response_id,
session_id=candidate_session_id,
):
yield event
await state.set_session(response_id, session)
return StreamingResponse(events(), media_type="text/event-stream")
result = await target.run(run["messages"], session=session, options=run["options"])
await state.set_session(response_id, session)
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
```
### One agent on multiple channels
## Validation
```python
host = AgentFrameworkHost(
target=agent,
channels=[
ResponsesChannel(),
InvocationsChannel(),
TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]),
],
)
Implementation validation must cover:
host.serve(host="localhost", port=8000)
```
The host owns one Starlette app. Each channel contributes its own routes and renders its own response.
### Adapting a request before execution
```python
from dataclasses import replace
def enforce_options(request: ChannelRequest) -> ChannelRequest:
options = dict(request.options or {})
options["temperature"] = 0
return replace(request, options=options)
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=enforce_options)],
)
```
### Workflow with explicit checkpoints
```python
host = AgentFrameworkHost(
target=workflow,
channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)],
checkpoint_location=Path("./.af-hosting/workflow_checkpoints"),
)
```
The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage.
### Message channel reset command
```python
async def new_chat(context):
if context.request.session is not None:
await context.host.reset_session(context.request.session.isolation_key)
await context.reply("Started a new conversation.")
```
Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them.
## Follow-up Enhancements
See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering:
- cross-channel identity linking,
- authorization and allowlists,
- non-originating response delivery,
- active-channel routing,
- multicast and all-linked delivery,
- background runs and continuation tokens,
- durable delivery runners,
- retry/replay semantics, and
- payload serialization.
Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them.
## Validation Gates
The Python implementation should be considered complete when:
- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition,
- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering,
- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it,
- workflow tests or samples use explicit `checkpoint_location`,
- Foundry isolation middleware is covered by integration or contract tests,
- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and
- this spec and ADR-0027 remain aligned.
- `SessionStore` plain get/set/delete behavior;
- `AgentState` target resolution, target caching, and get-or-create session behavior;
- `WorkflowState` target resolution for direct workflows, factories, `WorkflowBuilder`, and orchestration-style builders;
- Responses request parsing and option remapping;
- Responses session id extraction;
- Responses response rendering, including rich output item mapping;
- Responses streaming SSE rendering;
- HTTP round-trip tests showing a native FastAPI route using `AgentState` and Responses helpers;
- sample type checking for the local Responses sample.
- Telegram update parsing, chat/session/command/media extraction, final
rendering, and streaming edit rendering;
- sample type checking for the local Telegram polling and webhook entry points.
+7 -7
View File
@@ -11,8 +11,8 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.20.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.6.0" />
<PackageVersion Include="Anthropic" Version="12.35.1" />
<PackageVersion Include="Anthropic.Foundry" Version="0.7.1" />
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
<PackageVersion Include="Aspire.Azure.AI.Inference" Version="13.1.0-preview.1.25616.3" />
@@ -54,11 +54,11 @@
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.8" />
<!-- AG-UI .NET SDK packages (published by the AG-UI team). -->
<PackageVersion Include="AGUI.Abstractions" Version="0.0.1" />
<PackageVersion Include="AGUI.Formatting" Version="0.0.1" />
<PackageVersion Include="AGUI.Protobuf" Version="0.0.1" />
<PackageVersion Include="AGUI.Client" Version="0.0.1" />
<PackageVersion Include="AGUI.Server" Version="0.0.1" />
<PackageVersion Include="AGUI.Abstractions" Version="0.0.3" />
<PackageVersion Include="AGUI.Formatting" Version="0.0.3" />
<PackageVersion Include="AGUI.Protobuf" Version="0.0.3" />
<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.Tasks.Extensions" Version="4.6.3" />
+2
View File
@@ -123,6 +123,7 @@
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/Claw_Step02_WorkingWithData.csproj" />
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Claw_Step03_ScalingCapabilities.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
@@ -199,6 +200,7 @@
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock/AgentWithMemory_Step03_MemoryUsingValkey_Bedrock.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
+1
View File
@@ -28,6 +28,7 @@
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
"src\\Microsoft.Agents.AI.Purview\\Microsoft.Agents.AI.Purview.csproj",
"src\\Microsoft.Agents.AI.Tools.Shell\\Microsoft.Agents.AI.Tools.Shell.csproj",
"src\\Microsoft.Agents.AI.Valkey\\Microsoft.Agents.AI.Valkey.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative.Foundry\\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative.Mcp\\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj",
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
+10 -1
View File
@@ -427,6 +427,15 @@ internal static class AgentsSamples
],
},
new SampleDefinition
{
Name = "AgentWithMemory_Step06_MemoryUsingAgentMemory",
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory",
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
OptionalEnvironmentVariables = ["AZURE_OPENAI_API_KEY", "FOUNDRY_MODEL", "FOUNDRY_EMBEDDING_MODEL", "NEO4J_URI", "NEO4J_USER", "NEO4J_PASSWORD"],
SkipReason = "Requires a running Neo4j instance; standalone sample outside the repo's CPM build.",
},
// ── AgentWithRAG ────────────────────────────────────────────────────
new SampleDefinition
@@ -837,7 +846,7 @@ internal static class AgentsSamples
ProjectPath = "samples/02-agents/Agents/Agent_Step15_DeepResearch",
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT", "AZURE_AI_MODEL_DEPLOYMENT_NAME", "AZURE_AI_BING_CONNECTION_ID"],
OptionalEnvironmentVariables = ["AZURE_AI_REASONING_DEPLOYMENT_NAME"],
SkipReason = "Requires Azure AI Foundry project with Bing search connection.",
SkipReason = "Requires Microsoft Foundry project with Bing search connection.",
},
new SampleDefinition
+1 -1
View File
@@ -19,7 +19,7 @@
// Pre-build the solution before running, or pass --build to avoid missing build output failures.
//
// Required environment variables (for AI-powered verification):
// FOUNDRY_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint
// FOUNDRY_PROJECT_ENDPOINT — Your Microsoft Foundry project endpoint
// FOUNDRY_MODEL — Model deployment name (optional, defaults to gpt-5.4-mini)
using System.Diagnostics;
+1 -1
View File
@@ -130,7 +130,7 @@ internal static class WorkflowSamples
ProjectPath = "samples/03-workflows/Agents/FoundryAgent",
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
SkipReason = "Requires Azure AI Foundry project endpoint.",
SkipReason = "Requires Microsoft Foundry project endpoint.",
},
new SampleDefinition
@@ -66,6 +66,18 @@ When using multiple providers (e.g., skills + file access), combine their rules
})
```
## ⚠️ Security: avoid tool-name collisions
Built-in auto-approval rules match tool calls **solely by tool name**. A rule cannot tell the
provider's own tool apart from any other registered tool that happens to share the same name. If a
different tool — especially one with a caller-configurable name, such as the Harness shell tool
(`HarnessAgentOptions.ShellToolName`) — is registered under a name that one of these rules approves
(e.g. `load_skill`, `read_skill_resource`, `run_skill_script`, or the `file_access_*` names), that
tool will be **silently auto-approved**, bypassing the human approval boundary.
When using auto-approval rules, ensure no other tool's name collides with the reserved names the
rules approve, and never assign a configurable tool name that matches one of them.
## Skills Included
### unit-converter
@@ -0,0 +1,78 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
This project is part of the repo's solution and targets .NET 10 like the rest of the repo, but it
intentionally opts out of Central Package Management and source-referencing Microsoft.Agents.AI:
it consumes the *published* AgentMemory NuGet packages (which target Microsoft.Agents.AI 1.9.0)
instead. Run it with `dotnet run` from this folder.
ManagePackageVersionsCentrally is off, but dotnet/Directory.Packages.props still unconditionally
merges its repo-wide analyzer PackageReference items (no Version, resolved via CPM) into every
project that imports it — including this one. With CPM off here those versions can't resolve
(NU1015), so each is removed and re-added with an explicit version below (matching
AgentWithRAG_Step05_Neo4jGraphRAG, which hits the same issue). xunit.analyzers/Moq.Analyzers are
dropped rather than re-added since this project has no test code.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<RootNamespace>AgentMemoryShoppingAssistant</RootNamespace>
<!-- OPENAI001: the OpenAIClient(AuthenticationPolicy, options) ctor used for keyless Azure auth is
marked experimental in the OpenAI SDK (the MAF Foundry samples use the same pattern). -->
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<!-- AgentMemory (published) — an unofficial .NET port of the Neo4j Labs agent-memory library + its
Microsoft Agent Framework adapter. -->
<PackageReference Include="AgentMemory" Version="1.2.0" />
<PackageReference Include="AgentMemory.AgentFramework" Version="1.2.0" />
<!-- Microsoft Agent Framework (matches AgentMemory's target) + the OpenAI/Foundry chat & embedding clients. -->
<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.17" />
<!-- Transitive dependency of Microsoft.Agents.AI; pinned explicitly (CPM is off here) because the
version it would otherwise resolve to, 1.12.0, has a known moderate severity vulnerability
(GHSA-g94r-2vxg-569j) that fails the repo's NuGet audit (NU1902 as error). Matches the version
pinned in dotnet/Directory.Packages.props. -->
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text;
using AgentMemory.Neo4j.Infrastructure;
using Microsoft.Extensions.AI;
using Neo4j.Driver;
namespace AgentMemoryShoppingAssistant;
/// <summary>
/// A small retail product graph plus the shopping tools that query it — the .NET counterpart of the
/// Python retail-assistant's <c>get_product_tools</c>. Products live in Neo4j as <c>:Product</c> nodes
/// linked to <c>:ProductCategory</c> / <c>:ProductBrand</c> nodes, so recommendations and "related
/// products" come from graph traversals. Cypher runs through the public <see cref="INeo4jTransactionRunner"/>
/// seam. Exposed as <see cref="AIFunction"/>s so a real chat model can call them during a run — the same
/// way <c>Neo4jMemoryContextProvider</c> surfaces the memory tools through <c>AIContext.Tools</c> when
/// <c>ExposeMemoryToolsFromContextProvider</c> is enabled.
/// </summary>
public sealed class ProductCatalog(INeo4jTransactionRunner runner)
{
private readonly INeo4jTransactionRunner _runner = runner;
private static readonly (string Name, string Category, string Brand, double Price, bool InStock, int Inventory, string Description, int Popularity)[] s_seed =
[
("Nike Air Zoom Pegasus 40", "shoes", "Nike", 130, true, 40, "Everyday running shoe with responsive cushioning.", 95),
("Nike Revolution 7", "shoes", "Nike", 70, true, 60, "Lightweight, budget-friendly running shoe.", 80),
("Adidas Ultraboost Light", "shoes", "Adidas", 190, true, 25, "Premium running shoe with Boost cushioning.", 90),
("Asics Gel-Kayano 31", "shoes", "Asics", 165, false, 0, "Stability running shoe for overpronation.", 70),
("Sony WH-1000XM5", "electronics", "Sony", 350, true, 18, "Industry-leading noise-cancelling headphones.", 92),
("Bose QuietComfort Ultra", "electronics", "Bose", 330, true, 12, "Premium noise-cancelling over-ear headphones.", 85),
("Apple AirPods Pro 2", "electronics", "Apple", 250, true, 50, "Wireless earbuds with active noise cancellation.", 88),
("Garmin Forerunner 265", "electronics", "Garmin", 450, true, 9, "GPS running watch with training metrics.", 78),
("Nike Dri-FIT Running Tee", "apparel", "Nike", 35, true, 120, "Breathable, moisture-wicking running shirt.", 65),
("Adidas Own the Run Jacket","apparel", "Adidas", 80, true, 33, "Lightweight, water-repellent running jacket.", 60),
];
/// <summary>Seeds the sample product graph (idempotent — safe to run every start).</summary>
public Task SeedAsync(CancellationToken ct = default) => this._runner.WriteAsync(async r =>
{
await r.RunAsync(
"""
UNWIND $products AS row
MERGE (p:Product {name: row.name})
SET p.category = row.category, p.brand = row.brand, p.price = row.price,
p.in_stock = row.in_stock, p.inventory = row.inventory,
p.description = row.description, p.popularity = row.popularity
MERGE (c:ProductCategory {name: row.category})
MERGE (b:ProductBrand {name: row.brand})
MERGE (p)-[:IN_CATEGORY]->(c)
MERGE (p)-[:MADE_BY]->(b)
""",
new
{
products = s_seed.Select(p => (object)new Dictionary<string, object>
{
["name"] = p.Name, ["category"] = p.Category, ["brand"] = p.Brand, ["price"] = p.Price,
["in_stock"] = p.InStock, ["inventory"] = p.Inventory, ["description"] = p.Description,
["popularity"] = p.Popularity,
}).ToList(),
});
}, ct);
// ── Tools (also usable directly in the scripted demo) ────────────────────────────────────────
[Description("Search the product catalog for items matching a query, with optional category, brand, and max-price filters.")]
public Task<string> SearchProductsAsync(
[Description("What the customer is looking for, e.g. 'running shoes'.")] string query,
[Description("Optional category filter: shoes, electronics, apparel.")] string? category = null,
[Description("Optional brand filter, e.g. 'Nike'.")] string? brand = null,
[Description("Optional maximum price.")] double? maxPrice = null,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
const string Cypher =
"""
MATCH (p:Product)
WHERE ANY(w IN split(toLower($query), ' ') WHERE
toLower(p.name) CONTAINS w OR toLower(p.description) CONTAINS w OR toLower(p.category) CONTAINS w)
AND ($category IS NULL OR p.category = $category)
AND ($brand IS NULL OR p.brand = $brand)
AND ($maxPrice IS NULL OR p.price <= $maxPrice)
RETURN p.name AS name, p.brand AS brand, p.category AS category,
p.price AS price, p.in_stock AS inStock
ORDER BY p.popularity DESC
LIMIT 10
""";
var cursor = await r.RunAsync(Cypher, new { query, category, brand, maxPrice });
return Render("Matches", await cursor.ToListAsync());
}, ct);
[Description("Get personalized product recommendations, optionally biased toward a preferred brand and/or category.")]
public Task<string> GetRecommendationsAsync(
[Description("The customer's preferred brand (from their saved preferences), if known.")] string? preferredBrand = null,
[Description("Optional category to recommend within.")] string? category = null,
[Description("How many recommendations to return.")] int limit = 5,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
const string Cypher =
"""
MATCH (p:Product)
WHERE p.in_stock = true
AND ($category IS NULL OR p.category = $category)
WITH p, (CASE WHEN $preferredBrand IS NOT NULL AND p.brand = $preferredBrand THEN 1 ELSE 0 END) AS onBrand
RETURN p.name AS name, p.brand AS brand, p.category AS category, p.price AS price, p.in_stock AS inStock
ORDER BY onBrand DESC, p.popularity DESC
LIMIT $limit
""";
var cursor = await r.RunAsync(Cypher, new { preferredBrand, category, limit });
var header = preferredBrand is null ? "Recommended for you" : $"Recommended for you (favoring {preferredBrand})";
return Render(header, await cursor.ToListAsync());
}, ct);
[Description("Find products related to a given product — same category or same brand — via graph traversal.")]
public Task<string> GetRelatedProductsAsync(
[Description("The exact product name to find related items for.")] string productName,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
const string Cypher =
"""
MATCH (p:Product {name: $productName})
CALL (p) {
MATCH (p)-[:IN_CATEGORY]->(c)<-[:IN_CATEGORY]-(rel:Product) WHERE rel <> p
RETURN rel, 'same category' AS reason
UNION
MATCH (p)-[:MADE_BY]->(b)<-[:MADE_BY]-(rel:Product) WHERE rel <> p
RETURN rel, 'same brand' AS reason
}
WITH rel, collect(DISTINCT reason) AS reasons
RETURN rel.name AS name, rel.brand AS brand, rel.category AS category,
rel.price AS price, rel.in_stock AS inStock, rel.popularity AS popularity,
reduce(s = '', x IN reasons | CASE WHEN s = '' THEN x ELSE s + ', ' + x END) AS reason
ORDER BY popularity DESC
LIMIT 5
""";
var cursor = await r.RunAsync(Cypher, new { productName });
return Render($"Related to {productName}", await cursor.ToListAsync());
}, ct);
[Description("Check whether a product is in stock and how many units are available.")]
public Task<string> CheckInventoryAsync(
[Description("The exact product name to check.")] string productName,
CancellationToken ct = default) => this._runner.ReadAsync(async r =>
{
var cursor = await r.RunAsync(
"MATCH (p:Product {name: $productName}) RETURN p.name AS name, p.in_stock AS inStock, p.inventory AS inventory",
new { productName });
var rows = await cursor.ToListAsync();
if (rows.Count == 0)
{
return $"'{productName}' was not found in the catalog.";
}
var rec = rows[0];
var inStock = rec["inStock"].As<bool>();
return inStock
? $"{rec["name"].As<string>()}: In stock ({rec["inventory"].As<long>()} available)."
: $"{rec["name"].As<string>()}: Out of stock.";
}, ct);
/// <summary>The retail tools as MAF/MEAI <see cref="AIFunction"/>s (attach to the agent's ChatOptions.Tools).</summary>
public IReadOnlyList<AIFunction> CreateAIFunctions() =>
[
AIFunctionFactory.Create(this.SearchProductsAsync, "search_products",
"Search the product catalog with optional category/brand/price filters."),
AIFunctionFactory.Create(this.GetRecommendationsAsync, "get_recommendations",
"Get personalized recommendations, optionally favoring a preferred brand/category."),
AIFunctionFactory.Create(this.GetRelatedProductsAsync, "get_related_products",
"Find products related to a given product via the graph."),
AIFunctionFactory.Create(this.CheckInventoryAsync, "check_inventory",
"Check stock/availability for a product."),
];
private static string Render(string header, List<IRecord> rows)
{
if (rows.Count == 0)
{
return $"{header}: (no matches)";
}
var sb = new StringBuilder().Append(header).Append(':').AppendLine();
foreach (var rec in rows)
{
var stock = rec["inStock"].As<bool>() ? "in stock" : "out of stock";
var reason = rec.Keys.Contains("reason") ? $" [{rec["reason"].As<string>()}]" : string.Empty;
sb.Append(" • ")
.Append(rec["name"].As<string>())
.Append(" — ").Append(rec["brand"].As<string>())
.Append(", ").Append(rec["category"].As<string>())
.Append(", $").Append(rec["price"].As<double>().ToString("0"))
.Append(", ").Append(stock).Append(reason)
.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
// Agent Memory — Shopping Assistant (Microsoft Agent Framework, .NET)
//
// A .NET port of the Neo4j Labs "agent-memory" retail-assistant example
// (https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant,
// referenced from https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory).
//
// A shopping assistant that LEARNS a customer's preferences and RECOMMENDS products via graph
// traversal, backed by DURABLE memory in Neo4j. It uses the AgentMemory library — a .NET port of the
// Python memory provider, not an officially recognized Neo4j integration — and its Microsoft Agent
// Framework adapter:
// • Neo4jMemoryContextProvider (an AIContextProvider) — recalls memory before each run, persists
// after, and (via ExposeMemoryToolsFromContextProvider) surfaces the memory tools (search/remember/
// recall) itself through AIContext.Tools
// • ProductCatalog.CreateAIFunctions() — retail tools over a Neo4j :Product graph
//
// Configuration (environment variables, matching the other Foundry samples):
// AZURE_OPENAI_ENDPOINT (required) — your Azure OpenAI / Foundry endpoint
// AZURE_OPENAI_API_KEY (optional) — API key; if unset, DefaultAzureCredential (az login) is used
// FOUNDRY_MODEL (default: gpt-4o-mini) — chat model deployment
// FOUNDRY_EMBEDDING_MODEL (default: text-embedding-3-small) — embedding model deployment (1536 dims)
// NEO4J_URI (default: bolt://localhost:7687)
// NEO4J_USER (default: neo4j)
// NEO4J_PASSWORD (default: password)
using System.ClientModel;
using System.ClientModel.Primitives;
using AgentMemory.Abstractions.Services;
using AgentMemory.AgentFramework;
using AgentMemory.Core;
using AgentMemory.Core.Stubs;
using AgentMemory.Neo4j.Infrastructure;
using AgentMemoryShoppingAssistant;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI;
// ── Model + credentials (Azure OpenAI / Foundry, via env vars) ───────────────────────────────────
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
var chatModel = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o-mini";
var embeddingModel = Environment.GetEnvironmentVariable("FOUNDRY_EMBEDDING_MODEL") ?? "text-embedding-3-small";
var clientOptions = new OpenAIClientOptions { Endpoint = new Uri(endpoint) };
// API key if provided, otherwise Azure credential (dev: `az login`).
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
OpenAIClient openAI = string.IsNullOrWhiteSpace(apiKey)
? new OpenAIClient(new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), clientOptions)
: new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions);
IChatClient chatClient = openAI.GetChatClient(chatModel).AsIChatClient();
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator =
openAI.GetEmbeddingClient(embeddingModel).AsIEmbeddingGenerator();
// ── AgentMemory (Neo4j) DI ───────────────────────────────────────────────────────────────────────
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services.AddNeo4jAgentMemory(options =>
{
options.Uri = Environment.GetEnvironmentVariable("NEO4J_URI") ?? "bolt://localhost:7687";
options.Username = Environment.GetEnvironmentVariable("NEO4J_USER") ?? "neo4j";
options.Password = Environment.GetEnvironmentVariable("NEO4J_PASSWORD") ?? "password";
});
builder.Services.AddAgentMemoryCore(_ => { });
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IIdGenerator, GuidIdGenerator>();
builder.Services.TryAddSingleton(chatClient);
builder.Services.TryAddSingleton(embeddingGenerator);
builder.Services.AddAgentMemoryFramework(options =>
{
options.AutoExtractOnPersist = true;
options.ContextFormat.IncludeEntities = true;
options.ContextFormat.IncludeFacts = true;
options.ContextFormat.IncludePreferences = true;
options.ExposeMemoryToolsFromContextProvider = true;
});
var host = builder.Build();
await using var hostDisposal = (IAsyncDisposable)host;
await using var scope = host.Services.CreateAsyncScope();
var sp = scope.ServiceProvider;
// ── Setup: schema + sample product graph ─────────────────────────────────────────────────────────
var catalog = new ProductCatalog(sp.GetRequiredService<INeo4jTransactionRunner>());
await sp.GetRequiredService<ISchemaBootstrapper>().BootstrapAsync();
await catalog.SeedAsync();
Console.WriteLine("Neo4j schema ready; sample products loaded.\n");
// ── The shopping assistant: context provider (recall + memory tools) + product tools ─────────────
var memoryProvider = sp.GetRequiredService<Neo4jMemoryContextProvider>();
var productTools = catalog.CreateAIFunctions();
// WithMemoryOwnerScoping(sp) scopes the whole invocation (recall, tool calls, persistence) to the
// owner set via WithMemoryIdentity below — no manual BeginOwnerScope wrapping needed per turn.
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new ChatOptions
{
ModelId = chatModel,
Instructions =
"You are a helpful shopping assistant for an online store. Learn and remember the customer's "
+ "preferences (brands, budget, categories) using the memory tools, and recommend products that "
+ "fit using the product tools. Explain why each recommendation matches, and suggest alternatives "
+ "when something is out of stock.",
// memoryProvider appends the six memory tools (search_memory, remember_fact, ...) to this list
// on every model call via AIContext.Tools — see ExposeMemoryToolsFromContextProvider above.
Tools = [.. productTools],
},
AIContextProviders = [memoryProvider],
}).WithMemoryOwnerScoping(sp);
const string Shopper = "shopper-amelia";
// ── Session A — the customer shops; the model calls the tools and remembers preferences ──────────
Console.WriteLine(">> Session A\n");
var sessionA = (await agent.CreateSessionAsync())
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-a", applicationId: "retail-demo");
foreach (var turn in new[]
{
"Hi! I'm looking for running shoes. I love Nike and want to stay under $150.",
"Nice — what would you recommend for me, and is anything I might like out of stock?",
})
{
await SayAsync(agent, sessionA, turn);
}
// ── Session B — a NEW session for the same shopper still recalls her preferences ─────────────────
Console.WriteLine(">> Session B — a brand-new session; memory is durable\n");
var sessionB = (await agent.CreateSessionAsync())
.WithMemoryIdentity(userId: Shopper, sessionId: "cart-b", applicationId: "retail-demo");
await SayAsync(agent, sessionB, "I'm back — remind me what I like and suggest something new.");
Console.WriteLine("=== Done. Preferences + messages persist in Neo4j across sessions. ===");
// One conversational turn. Owner scoping (recall, tool calls, and persistence) is guaranteed
// automatically by the WithMemoryOwnerScoping-wrapped agent — no manual BeginOwnerScope needed here.
static async Task SayAsync(AIAgent agent, AgentSession session, string message)
{
Console.WriteLine($"USER : {message}");
var response = await agent.RunAsync(message, session);
Console.WriteLine($"ASSISTANT : {response.Text}\n");
}
@@ -0,0 +1,75 @@
# Agent with Memory Using AgentMemory — Shopping Assistant
A **.NET port of the Neo4j Labs "agent-memory" retail assistant** example
([`microsoft_agent_retail_assistant`](https://github.com/neo4j-labs/agent-memory/tree/main/examples/microsoft_agent_retail_assistant),
referenced from the [Learn integration page](https://learn.microsoft.com/en-us/agent-framework/integrations/neo4j-memory)).
A shopping assistant that **learns a customer's preferences** and **recommends products via graph
traversal**, backed by durable memory in Neo4j.
It uses the [`AgentMemory`](https://www.nuget.org/packages/AgentMemory) library — a .NET port of the
(Python-only) Neo4j Labs memory provider, **not an officially recognized Neo4j integration** — through
its Microsoft Agent Framework adapter.
## Features Demonstrated
- **`Neo4jMemoryContextProvider`** (an `AIContextProvider`) — recalls relevant memory before each run,
persists new memory after (the same bidirectional pattern as the official provider), and — via
`ExposeMemoryToolsFromContextProvider = true` — surfaces the memory tools (search / remember / recall)
itself through `AIContext.Tools`.
- **`ProductCatalog.CreateAIFunctions()`** — retail tools over a Neo4j `:Product` graph (search /
recommend / related / inventory).
- Preference learning that persists across a brand-new `AgentSession` for the same shopper.
- Graph-based product recommendations and "related products" via traversal.
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- A **Neo4j 5.x** instance (the sample bootstraps the schema and seeds sample products)
- An **Azure OpenAI / Foundry** deployment (a chat model + an embedding model)
## Configuration
Set the following environment variables:
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `AZURE_OPENAI_ENDPOINT` | ✅ | — | Azure OpenAI / Foundry endpoint |
| `AZURE_OPENAI_API_KEY` | — | — | API key; if unset, `DefaultAzureCredential` (`az login`) is used |
| `FOUNDRY_MODEL` | — | `gpt-4o-mini` | chat model deployment |
| `FOUNDRY_EMBEDDING_MODEL` | — | `text-embedding-3-small` | embedding model deployment (1536 dims) |
| `NEO4J_URI` | — | `bolt://localhost:7687` | Neo4j bolt URI |
| `NEO4J_USER` | — | `neo4j` | Neo4j user |
| `NEO4J_PASSWORD` | — | `password` | Neo4j password |
> Ensure the embedding model's dimensions match the Neo4j vector-index dimensions AgentMemory bootstraps
> (default 1536, which matches `text-embedding-3-small`).
## Run the Sample
```bash
docker run -d --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/password neo4j:5.26
export AZURE_OPENAI_ENDPOINT="https://<your-resource>.openai.azure.com"
export AZURE_OPENAI_API_KEY="<your-key>" # or omit and `az login`
export FOUNDRY_MODEL="gpt-4o-mini"
dotnet run
```
## Expected Output
1. The sample bootstraps the Neo4j schema and seeds a small product graph (`:Product`,
`:ProductCategory`, `:ProductBrand` nodes).
2. **Session A** — the shopper says she wants running shoes, loves Nike, and has a $150 budget; the
agent calls the memory tools to remember this and the product tools to recommend matching items.
3. **Session B** — a brand-new session for the same shopper (`shopper-amelia`) still recalls her
preferences and can suggest something new, because memory persists in Neo4j across sessions.
## Note on packaging
This sample is part of the repo's solution and targets .NET 10 like every other sample, but it
deliberately opts out of **Central Package Management** and does **not** reference `Microsoft.Agents.AI`
via the repo's in-source project — it consumes the **published** `AgentMemory` NuGet packages instead
(which target `Microsoft.Agents.AI` 1.9.0). A version that references the repo's current
`Microsoft.Agents.AI` source would require AgentMemory to be rebuilt against that version first.
@@ -9,6 +9,7 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.|
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
@@ -24,7 +24,7 @@ AIProjectClient aiProjectClient = new(
new Uri(endpoint),
new DefaultAzureCredential());
// Create an In-Memory vector store that uses the Azure AI Foundry embedding model to generate embeddings.
// Create an In-Memory vector store that uses the Microsoft Foundry embedding model to generate embeddings.
VectorStore vectorStore = new InMemoryVectorStore(new()
{
EmbeddingGenerator = aiProjectClient.GetProjectOpenAIClient().GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
@@ -25,7 +25,7 @@ AIProjectClient aiProjectClient = new(
new Uri(endpoint),
new DefaultAzureCredential());
// Create a Qdrant vector store that uses the Azure AI Foundry embedding model to generate embeddings.
// Create a Qdrant vector store that uses the Microsoft Foundry embedding model to generate embeddings.
QdrantClient client = new("localhost");
VectorStore vectorStore = new QdrantVectorStore(client, ownsClient: true, new()
{
@@ -3,7 +3,7 @@
// Structured Output — Configure agents to return typed JSON
//
// This sample shows how to configure a ChatClientAgent to produce
// structured output using JSON schema constraints with Azure AI Foundry.
// structured output using JSON schema constraints with Microsoft Foundry.
using System.ComponentModel;
using System.Text.Json;
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// Agent Observability — OpenTelemetry tracing with Azure AI Foundry
// Agent Observability — OpenTelemetry tracing with Microsoft Foundry
//
// This sample shows how to instrument an AI agent with OpenTelemetry
// for distributed tracing and telemetry logging.
@@ -19,7 +19,7 @@ var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt
// Create a host builder that we will register services with and then run.
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
// Create the AI agent from the Azure AI Foundry project client.
// Create the AI agent from the Microsoft Foundry project client.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
@@ -2,7 +2,7 @@
// Middleware — Chain multiple middleware layers on an agent
//
// This sample shows multiple middleware layers working together with Azure AI Foundry:
// This sample shows multiple middleware layers working together with Microsoft Foundry:
// chat client (global/per-request), agent run (PII filtering and guardrails),
// function invocation (logging and result overrides), human-in-the-loop
// approval workflows for sensitive function calls, and MessageAIContextProvider
@@ -15,7 +15,7 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
// Get Azure AI Foundry configuration from environment variables
// Get Microsoft Foundry configuration from environment variables
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
@@ -3,7 +3,7 @@
// Background Responses — Asynchronous agent execution with polling
//
// This sample shows how to use background responses with ChatClientAgent
// and Azure AI Foundry for non-blocking agent execution.
// and Microsoft Foundry for non-blocking agent execution.
using Azure.AI.Projects;
using Azure.Identity;
@@ -67,7 +67,7 @@ static string GetTime([Description("The city name.")] string city) =>
// asking for alternative destinations. The model will process this injected message on the next
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
[Description("Check current travel advisories for a city.")]
static string CheckTravelAdvisory([Description("The city name.")] string city)
static async Task<string> CheckTravelAdvisory([Description("The city name.")] string city)
{
// Simulated travel advisory data.
var advisory = city.ToUpperInvariant() switch
@@ -85,9 +85,13 @@ static string CheckTravelAdvisory([Description("The city name.")] string city)
// When an advisory is found, inject a follow-up question so the model automatically
// suggests alternatives without the user needing to ask.
var runContext = AIAgent.CurrentRunContext!;
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
runContext.Session!,
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
var injector = runContext.Agent.GetService<MessageInjectingChatClient>();
if (injector is not null)
{
await injector.EnqueueMessagesAsync(
runContext.Session!,
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
}
return advisory;
}
+1 -1
View File
@@ -18,7 +18,7 @@ Before you begin, ensure you have the following prerequisites:
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the required role to invoke models in the Foundry project.
**Note**: These samples use models hosted through Microsoft Foundry. For more information, see [Azure AI Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/).
**Note**: These samples use models hosted through Microsoft Foundry. For more information, see [Microsoft Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/).
**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Foundry project. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
@@ -12,7 +12,7 @@ The simplest agent evaluation: create a Foundry agent, run it against test quest
- .NET 10 SDK or later
- Azure authentication available to `DefaultAzureCredential` (for local development, run `az login`)
- A deployed model in your Azure AI Foundry project
- A deployed model in your Microsoft Foundry project
Set the following environment variables:
@@ -14,6 +14,12 @@ It builds on Post 1's personal finance assistant and teaches it to work with *yo
saving and deleting still pause for approval. The `place_trade` tool is also wrapped in an
`ApprovalRequiredAIFunction` (see `TradingTools.cs`), so the harness surfaces an approval prompt
before any trade runs. The trade itself is simulated — no real order is placed.
> ⚠️ **Security — avoid tool-name collisions:** auto-approval rules such as
> `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` match tool calls **solely by tool name**. Any
> other registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
> `file_access_grep`) would be silently auto-approved, bypassing the human
> approval boundary. Ensure no other tool's name collides with the reserved names a rule approves.
- **Durable memory, two ways:**
- **File memory** (coarse-grained, explicit) — the agent reads/writes files such as
`watchlist.md`. File memory is on by default; its files live on disk under
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http.Headers;
using Azure.Core;
using ModelContextProtocol.Client;
namespace ClawSample;
/// <summary>
/// Helpers for wiring centrally-managed <b>Foundry skills</b> into the claw via a Foundry Toolbox
/// MCP endpoint. These are opt-in: skills published to the toolbox are discovered at runtime, so
/// they can be managed and updated without changing or redeploying the agent.
/// </summary>
internal static class FoundrySkills
{
/// <summary>
/// Connects to a Foundry Toolbox MCP endpoint and returns a connected <see cref="McpClient"/>.
/// The caller owns the returned client and its HTTP client.
/// </summary>
/// <param name="toolboxMcpServerUrl">The Foundry Toolbox MCP server URL.</param>
/// <param name="credential">Credential used to obtain a bearer token for the toolbox.</param>
/// <returns>The connected MCP client and the underlying HTTP client; both must be disposed by the caller.</returns>
public static async Task<(McpClient McpClient, HttpClient HttpClient)> ConnectAsync(
string toolboxMcpServerUrl,
TokenCredential credential)
{
var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
{
InnerHandler = new HttpClientHandler(),
});
try
{
McpClient mcpClient = await McpClient.CreateAsync(
new HttpClientTransport(
new HttpClientTransportOptions
{
Endpoint = new Uri(toolboxMcpServerUrl),
Name = "foundry_toolbox",
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string>
{
["Foundry-Features"] = "Toolboxes=V1Preview",
},
},
httpClient));
return (mcpClient, httpClient);
}
catch
{
// The MCP client never took ownership of the HTTP client, so dispose it here.
httpClient.Dispose();
throw;
}
}
private sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
{
private readonly TokenRequestContext _tokenContext = new([scope]);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,228 @@
// Copyright (c) Microsoft. All rights reserved.
// "Scaling its capabilities" — Post 3 of the "Build your own claw and agent harness with Microsoft
// Agent Framework" series.
// See: https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/.
//
// This sample builds on Post 2's personal finance assistant and makes it *more capable* in four ways:
// 1. Skills — package finance know-how (valuation, risk-scoring) as discoverable SKILL.md
// files the agent loads on demand. Optionally fold in centrally-managed Foundry
// skills from a Foundry Toolbox MCP endpoint (opt-in via FOUNDRY_TOOLBOX_MCP_SERVER_URL).
// 2. Shell — a sandboxed shell, confined to the trade-confirmation vault, that the agent
// uses to reorganize the accumulated confirmation files (year/month, rename,
// archive). Guarded by a deny-list policy and a confined working directory.
// 3. CodeAct — the agent writes and runs Python to crunch portfolio numbers, in a sandboxed
// Hyperlight micro-VM (needs hardware virtualization).
// 4. Background agents — fan out a per-ticker research sub-agent so several tickers are researched
// concurrently, then aggregated.
//
// Special commands (handled by the shared HarnessConsole):
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using ClawSample;
using Harness.Shared.Console;
using Harness.Shared.Console.OpenAI;
using Harness.Shared.Console.ToolFormatters;
using HyperlightSandbox.Guest.Python;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Agents.AI.Tools.Shell;
using Microsoft.Extensions.AI;
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";
// The two folders the claw works in: the working folder (portfolio.csv, reports) and the
// trade-confirmation "vault" inside it that the shell will reorganize.
var workingDir = Path.Combine(AppContext.BaseDirectory, "working");
var vaultDir = Path.Combine(workingDir, "confirmations");
var skillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
// <instructions>
var instructions =
"""
## Personal Finance Assistant Instructions
You are a personal finance and investing assistant. You help the user understand their
portfolio and watchlist, value individual stocks, gauge portfolio risk, research the market,
and keep their records tidy.
### Working style
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
before answering questions about their portfolio, and never modify it unless asked.
- You have skills for valuation and risk-scoring. When a question matches a skill, load it and
follow its instructions (read its references, run its scripts) rather than guessing.
- When asked to research several tickers, delegate each one to the background research agent so
they run concurrently, then summarize the findings together.
- The user's trade confirmations accumulate in the working/confirmations folder. When asked to
tidy or reorganize them, use the run_shell tool: inspect the folder first, then move files into
a year/month layout and rename them to YYYY-MM-DD_TICKER_BUY|SELL.txt. Explain your plan before
running commands that change anything.
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked
to approve it before it runs explain what you are about to do first.
### Important
You provide information and analysis only you are not a licensed financial advisor and you
must not present your output as personalized investment advice. Remind the user to do their own
research before making decisions.
""";
// </instructions>
// <create_client>
// Construct an IChatClient backed by a Microsoft Foundry project (see Post 1 for details).
var credential = new DefaultAzureCredential();
var projectClient = new AIProjectClient(
new Uri(endpoint),
// 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.
credential,
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
IChatClient chatClient = projectClient
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName);
// </create_client>
// <skills>
// The harness turns a skills provider on by default (it discovers SKILL.md files from the working
// directory). Here we build our own so we can point it at this sample's skills/ folder and, when
// configured, fold in centrally-managed Foundry skills — all behind one provider.
var skillsBuilder = new AgentSkillsProviderBuilder()
// File-based skills: valuation and risk-scoring. SubprocessScriptRunner runs their Python scripts.
.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync);
// Foundry skills (opt-in): discovered live from a Foundry Toolbox MCP endpoint, so they can be
// managed and updated centrally without changing or redeploying this agent.
HttpClient? toolboxHttpClient = null;
ModelContextProtocol.Client.McpClient? toolboxMcpClient = null;
var toolboxUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL");
if (!string.IsNullOrWhiteSpace(toolboxUrl))
{
(toolboxMcpClient, toolboxHttpClient) = await FoundrySkills.ConnectAsync(toolboxUrl, credential);
skillsBuilder.UseMcpSkills(toolboxMcpClient);
Console.WriteLine("Foundry skills enabled (Toolbox MCP).");
}
else
{
Console.WriteLine("Foundry skills disabled. Set FOUNDRY_TOOLBOX_MCP_SERVER_URL to enable them.");
}
AgentSkillsProvider skillsProvider = skillsBuilder.Build();
// </skills>
// <background>
// Background agents: a lean, web-search-only research sub-agent. Passing it to the harness exposes
// the background_agents_* tools so the claw can start several research tasks concurrently and
// collect the results.
AIAgent researchAgent = ResearchAgent.Create(chatClient);
// </background>
// <shell>
// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors
// every command to the vault, and the deny-list policy pre-filters obviously destructive commands.
// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.)
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
{
WorkingDirectory = vaultDir,
ConfineWorkingDirectory = true,
Policy = new ShellPolicy(denyList:
[
@"\brm\s+-rf\b",
@"\bsudo\b",
@":\(\)\s*\{", // fork-bomb shape
@"\bmkfs\b",
@">\s*/dev/sd",
]),
Timeout = TimeSpan.FromSeconds(15),
});
// </shell>
// <codeact>
// CodeAct: a sandboxed Python interpreter the model can write and run code in to crunch numbers.
// It runs on Hyperlight (a micro-VM, so it needs hardware virtualization). The guest module path is
// resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
// </codeact>
// <create_agent>
// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the
// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and
// CodeAct.
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
// File access: portfolio.csv, reports, and the confirmations vault all live under working/.
FileAccessStore = new FileSystemAgentFileStore(workingDir),
// We supply our own skills provider (file + optional Foundry), so turn off the default one.
DisableAgentSkillsProvider = true,
// Fan-out research is delegated to this background agent.
BackgroundAgents = [researchAgent],
// The confined shell, exposed as the approval-gated run_shell tool.
ShellExecutor = shell,
// Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt.
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
{
AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule],
},
// Start in "execute" mode for quick lookups and actions; switch any time with /mode plan.
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
// Our skills provider plus CodeAct.
AIContextProviders = contextProviders,
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools =
[
StockTools.CreateGetStockPriceTool(),
TradingTools.CreatePlaceTradeTool(),
],
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
// </create_agent>
try
{
// <run>
// Run the interactive console session. The default planning observers already include a tool
// approval observer, so the place_trade and run_shell approval prompts are surfaced automatically.
await HarnessConsole.RunAgentAsync(
agent,
userPrompt: "Ask me to value a stock, score your portfolio risk, research some tickers, or tidy your trade confirmations.",
new HarnessConsoleOptions
{
Observers = [
new OpenAIResponsesWebSearchDisplayObserver(),
new OpenAIResponsesErrorObserver(),
.. HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
// </run>
}
finally
{
codeAct?.Dispose();
if (toolboxMcpClient is not null)
{
await toolboxMcpClient.DisposeAsync().ConfigureAwait(false);
}
toolboxHttpClient?.Dispose();
}
@@ -0,0 +1,80 @@
# Scaling its capabilities (Post 3) — .NET
The third runnable sample from the [**"Build your own claw and agent harness with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
series ([Part 3 — Scaling its capabilities](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)).
It builds on Post 2's personal finance assistant and makes it *more capable* along four axes.
## What this sample demonstrates
- **Skills** — finance know-how (`valuation`, `risk-scoring`) is packaged as discoverable `SKILL.md`
files under `skills/`, which the agent loads on demand. The sample builds its own provider with
`AgentSkillsProviderBuilder.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync)`
so the skills' Python scripts can run, and sets `DisableAgentSkillsProvider = true` to replace the
harness default. Optionally folds in centrally-managed **Foundry skills** discovered live from a
Foundry **Toolbox MCP** endpoint via `FoundrySkills.ConnectAsync(...)` + `UseMcpSkills(...)`
(opt-in; see below).
- **Shell** — a `LocalShellExecutor` confined to the trade-confirmation vault
(`working/confirmations/`) lets the agent tidy the accumulated confirmation files (reorganize into
`year/month`, rename to `YYYY-MM-DD_TICKER_BUY|SELL.txt`). `ConfineWorkingDirectory` re-anchors
every command to the vault and a `ShellPolicy` deny-list pre-filters obviously destructive
commands. Exposed as the `run_shell` tool, which prompts for approval before each command runs.
(The deny-list is a UX guardrail, not a security boundary — for hard isolation use a
`DockerShellExecutor`.)
- **CodeAct** — a `HyperlightCodeActProvider` gives the agent a sandboxed Python interpreter to
crunch portfolio numbers by writing and running code. It runs on Hyperlight (a micro-VM), so it
requires hardware virtualization. The guest module path is resolved automatically from the
`Hyperlight.HyperlightSandbox.Guest.Python` NuGet package via `PythonGuestModule.GetModulePath()`.
- **Background agents** — a lean, web-search-only `ResearchAgent` is registered via
`HarnessAgentOptions.BackgroundAgents`, exposing the `background_agents_*` tools so the main agent
can fan out per-ticker research concurrently and aggregate the findings.
## Prerequisites
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
2. Azure CLI installed and authenticated (`az login`).
3. *(For CodeAct)* a host with hardware virtualization enabled (Hyperlight runs the Python
interpreter in a micro-VM).
## Environment variables
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
# Optional (defaults to gpt-5.4)
export FOUNDRY_MODEL="gpt-5.4"
# Optional — enable centrally-managed Foundry skills (Foundry Toolbox MCP endpoint URL):
export FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-project.services.ai.azure.com/.../toolboxes/your-toolbox/mcp?api-version=v1"
```
When `FOUNDRY_TOOLBOX_MCP_SERVER_URL` is not set, the sample runs with the local file skills only and
prints a note.
## Running
```bash
cd dotnet
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities
```
## What to expect
The sample starts an interactive loop in **execute** mode (quick lookups don't need a plan). Try
these in order:
1. `Value MSFT for me.` — the agent loads the `valuation` skill and follows its instructions
(reading references and running its script).
2. `Score the risk of my portfolio.` — the agent reads `portfolio.csv` and loads the `risk-scoring`
skill.
3. `/mode plan`, then `Tidy up my trade confirmations.` — switching to plan mode first makes the
agent inspect `working/confirmations/` and propose a reorganization plan before touching anything;
once you approve it switches to execute and uses the shell to reorganize and rename the files,
**prompting you to approve** each command.
4. `Work out the total value of my portfolio.` — the agent writes and runs Python via CodeAct.
5. `Research MSFT, NVDA and SPY and summarize the latest news.` — the agent fans the tickers out to
the background research agent and aggregates the results.
6. `What's the capital of France?` — with a `financial-agent-rules` skill published to your Foundry
toolbox and Foundry skills enabled (`FOUNDRY_TOOLBOX_MCP_SERVER_URL`), the agent loads it,
recognizes the question is off-topic, and politely declines, steering you back to finance.
See the [Part 3 blog post](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)
for more on the `financial-agent-rules` skill — including the SKILL.md to publish to your Foundry toolbox.
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace ClawSample;
/// <summary>
/// Builds the background "research" agent that the main claw fans work out to.
/// </summary>
/// <remarks>
/// This sub-agent doesn't need any of the harness machinery, so it's a plain
/// <see cref="ChatClientAgent"/> with a single tool: the hosted web search. The parent claw
/// delegates a per-ticker research task to one of these and they run concurrently.
/// </remarks>
internal static class ResearchAgent
{
/// <summary>Creates a web-search-only background agent for delegated ticker research.</summary>
/// <param name="chatClient">The chat client the background agent should use.</param>
public static AIAgent Create(IChatClient chatClient) =>
chatClient.AsAIAgent(
instructions:
"You research a single stock ticker. Use the web search tool to find the most " +
"recent, relevant news and commentary, then return a short, factual summary " +
"(3-4 bullet points) with no preamble.",
name: "TickerResearchAgent",
description: "Searches the web for recent news and commentary about a single stock ticker.",
// The only tool it needs: the same hosted web search the harness would have added.
tools: [new HostedWebSearchTool()]);
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Microsoft.Extensions.AI;
namespace ClawSample;
/// <summary>
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
/// </summary>
/// <remarks>
/// The prices and earnings figures returned here are mock data for demonstration purposes only and
/// are not real market quotes. In a real assistant you would call a market-data API instead. The
/// trailing earnings-per-share value is included so the valuation skill has something to work with.
/// </remarks>
internal static class StockTools
{
/// <summary>A delayed, illustrative stock quote, including a trailing earnings-per-share figure.</summary>
public sealed record StockQuote(string Symbol, decimal Price, decimal TrailingEps, string Currency, DateTimeOffset AsOf);
// A tiny in-memory book of (price, trailing EPS) so the sample runs without any external dependency.
private static readonly Dictionary<string, (decimal Price, decimal Eps)> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
{
["MSFT"] = (462.97m, 11.80m),
["AAPL"] = (229.35m, 6.13m),
["GOOGL"] = (178.12m, 7.54m),
["AMZN"] = (201.45m, 4.18m),
["NVDA"] = (134.81m, 2.95m),
["SPY"] = (612.40m, 23.10m),
};
/// <summary>
/// Gets the latest (delayed, illustrative) stock price and trailing EPS for a ticker symbol.
/// </summary>
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
[Description("Gets the latest (delayed, illustrative) stock price and trailing earnings per share for a ticker symbol.")]
public static StockQuote GetStockPrice(
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
{
if (!s_priceBook.TryGetValue(symbol, out var data))
{
// Deterministic pseudo-values for unknown symbols so the sample stays self-contained.
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
var seed = 0;
foreach (var ch in symbol.ToUpperInvariant())
{
seed = (seed * 31 + ch) % 1_000_000;
}
var price = 50m + seed % 45000 / 100m;
data = (price, Math.Round(price / 20m, 2));
}
return new StockQuote(symbol.ToUpperInvariant(), data.Price, data.Eps, "USD", DateTimeOffset.UtcNow);
}
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
}
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
// Sample subprocess-based skill script runner.
// Executes file-based skill scripts as local subprocesses.
// This is provided for demonstration purposes only.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
/// <summary>
/// Executes file-based skill scripts as local subprocesses.
/// </summary>
/// <remarks>
/// This runner uses the script's absolute path and converts the arguments
/// to CLI arguments. When the LLM sends a JSON array, each element is used
/// as a positional argument. It is intended for demonstration purposes only.
/// </remarks>
internal sealed class SubprocessScriptRunner
{
/// <summary>Maximum time a skill script is allowed to run before it is terminated.</summary>
private static readonly TimeSpan s_scriptTimeout = TimeSpan.FromSeconds(30);
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SubprocessScriptRunner"/> class.
/// </summary>
/// <param name="loggerFactory">
/// Optional logger factory. When provided, script outcomes (success output, stderr, non-zero
/// exit codes, and failures) are written to the log in addition to being returned to the LLM.
/// </param>
public SubprocessScriptRunner(ILoggerFactory? loggerFactory = null)
{
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<SubprocessScriptRunner>();
}
/// <summary>
/// Runs a skill script as a local subprocess.
/// </summary>
public async Task<object?> RunAsync(
AgentFileSkill skill,
AgentFileSkillScript script,
JsonElement? arguments,
IServiceProvider? serviceProvider,
CancellationToken cancellationToken)
{
this._logger.LogDebug("Running script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
if (!File.Exists(script.FullPath))
{
this._logger.LogError("Script file not found for skill '{SkillName}': {ScriptPath}", skill.Frontmatter.Name, script.FullPath);
return $"Error: Script file not found: {script.FullPath}";
}
string extension = Path.GetExtension(script.FullPath);
string? interpreter = extension switch
{
// Windows Python installs commonly expose "python" rather than "python3".
".py" => OperatingSystem.IsWindows() ? "python" : "python3",
".js" => "node",
".sh" => "bash",
".ps1" => "pwsh",
_ => null,
};
var startInfo = new ProcessStartInfo
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
};
if (interpreter is not null)
{
startInfo.FileName = interpreter;
startInfo.ArgumentList.Add(script.FullPath);
}
else
{
startInfo.FileName = script.FullPath;
}
if (arguments is { ValueKind: JsonValueKind.Array } json)
{
// Positional CLI arguments
foreach (var element in json.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.String)
{
throw new InvalidOperationException(
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
"All array elements must be JSON strings.");
}
startInfo.ArgumentList.Add(element.GetString()!);
}
}
else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined)
{
throw new InvalidOperationException(
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
"File-based skill scripts expect positional arguments as a JSON array of strings.");
}
// Bound the script's lifetime: cancel after a timeout, or when the caller cancels.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(s_scriptTimeout);
CancellationToken runToken = timeoutCts.Token;
Process? process = null;
try
{
process = Process.Start(startInfo);
if (process is null)
{
this._logger.LogError("Failed to start process for script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
return $"Error: Failed to start process for script '{script.Name}'.";
}
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(runToken);
Task<string> errorTask = process.StandardError.ReadToEndAsync(runToken);
await process.WaitForExitAsync(runToken).ConfigureAwait(false);
string output = await outputTask.ConfigureAwait(false);
string error = await errorTask.ConfigureAwait(false);
if (!string.IsNullOrEmpty(error))
{
if (process.ExitCode == 0)
{
this._logger.LogWarning(
"Script '{ScriptName}' from skill '{SkillName}' succeeded but wrote to stderr:\n{Stderr}",
script.Name, skill.Frontmatter.Name, error.Trim());
}
output += $"\nStderr:\n{error}";
}
if (process.ExitCode != 0)
{
this._logger.LogError(
"Script '{ScriptName}' from skill '{SkillName}' exited with code {ExitCode}.{Stderr}",
script.Name, skill.Frontmatter.Name, process.ExitCode,
string.IsNullOrEmpty(error) ? string.Empty : $"\nStderr:\n{error.Trim()}");
output += $"\nScript exited with code {process.ExitCode}";
}
string result = string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
if (process.ExitCode == 0)
{
this._logger.LogInformation(
"Script '{ScriptName}' from skill '{SkillName}' completed successfully. Output:\n{Output}",
script.Name, skill.Frontmatter.Name, result);
}
return result;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The timeout fired (the caller did not cancel). Kill the process and report a timeout.
process?.Kill(entireProcessTree: true);
this._logger.LogError(
"Script '{ScriptName}' from skill '{SkillName}' timed out after {Timeout} seconds.",
script.Name, skill.Frontmatter.Name, s_scriptTimeout.TotalSeconds);
return $"Error: Script '{script.Name}' timed out after {s_scriptTimeout.TotalSeconds:0} seconds.";
}
catch (OperationCanceledException)
{
// The caller cancelled: kill the process to avoid leaving orphaned subprocesses, then rethrow.
process?.Kill(entireProcessTree: true);
throw;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Failed to execute script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
}
finally
{
process?.Dispose();
}
}
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Microsoft.Extensions.AI;
namespace ClawSample;
/// <summary>
/// Sensitive "claw" tools that take real-world actions and therefore require human approval.
/// </summary>
/// <remarks>
/// These tools only simulate their effects (no orders are placed, no email is sent). They exist
/// to demonstrate how the harness gates risky actions behind an approval prompt.
/// </remarks>
internal static class TradingTools
{
// <place_trade>
/// <summary>
/// Places a (simulated) buy or sell order for a given symbol and quantity.
/// </summary>
/// <param name="symbol">The stock ticker symbol to trade, e.g. <c>MSFT</c>.</param>
/// <param name="action">Either <c>buy</c> or <c>sell</c>.</param>
/// <param name="quantity">The number of shares to trade.</param>
[Description("Places a buy or sell order for a given symbol and quantity.")]
public static string PlaceTrade(
[Description("The stock ticker symbol to trade, e.g. MSFT.")] string symbol,
[Description("Either 'buy' or 'sell'.")] string action,
[Description("The number of shares to trade.")] int quantity)
{
var isBuy = action.Equals("buy", StringComparison.OrdinalIgnoreCase);
var isSell = action.Equals("sell", StringComparison.OrdinalIgnoreCase);
if (!isBuy && !isSell)
{
return $"Invalid action '{action}'. Use 'buy' or 'sell'.";
}
if (quantity <= 0)
{
return $"Invalid quantity '{quantity}'. Quantity must be a positive whole number of shares.";
}
var verb = isSell ? "Sold" : "Bought";
var confirmation = $"TRADE-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
return $"{verb} {quantity} share(s) of {symbol.ToUpperInvariant()}. Confirmation: {confirmation}.";
}
// </place_trade>
/// <summary>
/// Creates an approval-required <see cref="AIFunction"/> for <see cref="PlaceTrade"/>.
/// Wrapping the function in <see cref="ApprovalRequiredAIFunction"/> tells the harness to
/// surface an approval request before the function ever runs.
/// </summary>
public static AIFunction CreatePlaceTradeTool() =>
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade"));
}
@@ -0,0 +1,18 @@
---
name: risk-scoring
description: Score how concentrated and risky a portfolio is on a 0-100 scale from its position weights. Use when the user asks how risky their portfolio is, whether it is too concentrated, or for a diversification check.
---
## Usage
When the user asks about portfolio risk or concentration:
1. Read `references/risk-bands.md` to understand the score bands and what drives them.
2. Compute each holding's market value (shares × price) — use the `get_stock_price` tool for current
prices if you do not already have them.
3. Run `scripts/risk_score.py` with one `--position VALUE` argument per holding,
e.g. `--position 18518 --position 17201 --position 16177`.
4. Report the 0-100 score, the band it falls in, and the largest single-position weight, then suggest
(in general terms) whether the portfolio looks well diversified or concentrated.
Remind the user this is a crude concentration measure, not a complete risk model, and not advice.
@@ -0,0 +1,27 @@
# Risk-scoring guide (illustrative)
This skill scores **concentration risk** — how much a portfolio depends on its largest positions —
on a 0-100 scale, where higher means riskier.
## How the score is built
1. Convert each position to a weight: `weight = position_value / total_value`.
2. Compute the Herfindahl-Hirschman Index (HHI): `HHI = sum(weight^2)`.
- A perfectly even portfolio of *n* holdings has `HHI = 1/n` (low).
- A single-stock portfolio has `HHI = 1` (maximum concentration).
3. Scale to 0-100: `score = round(HHI * 100)`.
## Score bands
| Score | Band | Interpretation |
|---------|--------------------|-------------------------------------------------|
| 0-20 | Well diversified | No single holding dominates. |
| 21-40 | Moderately diversified | Some tilt, but broadly spread. |
| 41-60 | Concentrated | A few positions carry most of the risk. |
| 61-100 | Highly concentrated| Heavily dependent on one or two positions. |
Also watch the **largest single-position weight**: above ~25% is usually worth flagging regardless
of the overall score.
This measures concentration only — it ignores volatility, correlation, sector exposure, and leverage,
so it is a starting point, not a verdict.
@@ -0,0 +1,58 @@
# Portfolio risk-scoring script
# Scores concentration risk on a 0-100 scale using the Herfindahl-Hirschman Index (HHI).
#
# weight_i = position_i / total
# HHI = sum(weight_i ^ 2)
# score = round(HHI * 100) # higher = more concentrated = riskier
#
# Usage:
# python scripts/risk_score.py --position 18518 --position 17201 --position 16177
import argparse
import json
def main() -> None:
parser = argparse.ArgumentParser(description="Score portfolio concentration risk (0-100).")
parser.add_argument(
"--position",
type=float,
action="append",
required=True,
help="Market value of one holding. Pass once per position.",
)
args = parser.parse_args()
positions = args.position
if any(p <= 0 for p in positions):
print(json.dumps({"error": "Each position value must be a positive market value."}))
return
total = sum(positions)
if total <= 0:
print(json.dumps({"error": "Total portfolio value must be positive."}))
return
weights = [p / total for p in positions]
hhi = sum(w * w for w in weights)
score = round(hhi * 100)
if score <= 20:
band = "Well diversified"
elif score <= 40:
band = "Moderately diversified"
elif score <= 60:
band = "Concentrated"
else:
band = "Highly concentrated"
print(json.dumps({
"positions": len(positions),
"score": score,
"band": band,
"largest_weight_pct": round(max(weights) * 100, 1),
}))
if __name__ == "__main__":
main()
@@ -0,0 +1,17 @@
---
name: valuation
description: Estimate whether a stock looks cheap or expensive using a price-to-earnings (P/E) based fair-value method. Use when the user asks if a stock is over- or under-valued, or for a fair-value / target price.
---
## Usage
When the user asks whether a stock is fairly valued, over-valued, or under-valued:
1. Read `references/valuation-guide.md` to pick a sensible target P/E for the company's sector.
2. Run `scripts/valuation_metrics.py` with the current price, trailing EPS, and the target P/E,
e.g. `--price 462.97 --eps 11.80 --target-pe 32`.
3. Report the computed P/E, the fair-value estimate, and the percentage upside/downside, then state
plainly whether the stock looks cheap or expensive on this measure.
Always remind the user that a single P/E heuristic is not investment advice and ignores growth,
debt, and many other factors.
@@ -0,0 +1,28 @@
# Valuation guide (illustrative)
A quick price-to-earnings (P/E) sanity check:
- **P/E = price ÷ trailing earnings per share (EPS)**
- **Fair value = trailing EPS × target P/E**
- **Upside/downside = (fair value price) ÷ price**
## Typical target P/E by sector
These are rough, illustrative anchors only — not live market multiples.
| Sector | Conservative target P/E | Growth target P/E |
|-----------------------|-------------------------|-------------------|
| Mega-cap technology | 28 | 35 |
| Semiconductors | 25 | 40 |
| Consumer staples | 18 | 22 |
| Financials / banks | 11 | 14 |
| Broad market (index) | 19 | 21 |
## How to read the result
- Fair value **well above** the current price ⇒ the stock looks **cheap** on this measure.
- Fair value **well below** the current price ⇒ the stock looks **expensive** on this measure.
- Within ~5% ⇒ roughly **fairly valued**.
This is one crude lens. It ignores growth rates, balance-sheet strength, and cash flow, so never
present it as a recommendation.
@@ -0,0 +1,57 @@
# Valuation metrics script
# Computes a simple price-to-earnings (P/E) based fair-value estimate.
#
# fair_value = eps * target_pe
# pe = price / eps
# upside = (fair_value - price) / price
#
# Usage:
# python scripts/valuation_metrics.py --price 462.97 --eps 11.80 --target-pe 32
import argparse
import json
def main() -> None:
parser = argparse.ArgumentParser(description="Compute a P/E based fair-value estimate.")
parser.add_argument("--price", type=float, required=True, help="Current share price.")
parser.add_argument("--eps", type=float, required=True, help="Trailing earnings per share.")
parser.add_argument("--target-pe", type=float, required=True, help="Target P/E from the guide.")
args = parser.parse_args()
if args.eps <= 0:
print(json.dumps({"error": "EPS must be positive to compute a P/E ratio."}))
return
if args.price <= 0:
print(json.dumps({"error": "Price must be positive to compute valuation metrics."}))
return
if args.target_pe <= 0:
print(json.dumps({"error": "Target P/E must be positive."}))
return
pe = args.price / args.eps
fair_value = args.eps * args.target_pe
upside = (fair_value - args.price) / args.price
if upside > 0.05:
verdict = "looks cheap"
elif upside < -0.05:
verdict = "looks expensive"
else:
verdict = "roughly fairly valued"
print(json.dumps({
"price": round(args.price, 2),
"eps": round(args.eps, 2),
"target_pe": round(args.target_pe, 2),
"pe": round(pe, 2),
"fair_value": round(fair_value, 2),
"upside_pct": round(upside * 100, 1),
"verdict": verdict,
}))
if __name__ == "__main__":
main()
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-55AA44BB
Date: 2025-06-21
Symbol: NVDA
Action: SELL
Quantity: 20
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-77CC88DD
Date: 2024-05-08
Symbol: SPY
Action: SELL
Quantity: 15
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-9F8E7D6C
Date: 2024-11-03
Symbol: AAPL
Action: BUY
Quantity: 75
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-1234ABCD
Date: 2025-09-12
Symbol: AMZN
Action: BUY
Quantity: 30
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-EE11FF22
Date: 2025-01-30
Symbol: GOOGL
Action: BUY
Quantity: 25
@@ -0,0 +1,6 @@
TRADE CONFIRMATION
Confirmation: TRADE-A1B2C3D4
Date: 2024-02-14
Symbol: MSFT
Action: BUY
Quantity: 40
@@ -0,0 +1,7 @@
symbol,shares,cost_basis,purchase_date
MSFT,40,312.50,2023-02-14
AAPL,75,168.20,2022-11-03
NVDA,120,42.80,2021-06-21
AMZN,30,142.10,2023-09-12
GOOGL,25,128.45,2024-01-30
SPY,60,418.90,2024-05-08
1 symbol shares cost_basis purchase_date
2 MSFT 40 312.50 2023-02-14
3 AAPL 75 168.20 2022-11-03
4 NVDA 120 42.80 2021-06-21
5 AMZN 30 142.10 2023-09-12
6 GOOGL 25 128.45 2024-01-30
7 SPY 60 418.90 2024-05-08
@@ -43,7 +43,7 @@ public sealed class ModeCommandHandler : CommandHandler
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
string current = this._modeProvider.GetMode(session);
string current = await this._modeProvider.GetModeAsync(session).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
return true;
}
@@ -52,7 +52,7 @@ public sealed class ModeCommandHandler : CommandHandler
try
{
this._modeProvider.SetMode(session, newMode);
await this._modeProvider.SetModeAsync(session, newMode).ConfigureAwait(false);
ux.CurrentMode = newMode;
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
}
@@ -93,7 +93,7 @@ public sealed class HarnessAgentRunner : IDisposable
{
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
{
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
return;
}
}
@@ -111,16 +111,15 @@ public sealed class HarnessAgentRunner : IDisposable
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
/// by the agent on its next opportunity.
/// </summary>
internal Task OnStreamingInputAsync(string text)
internal async Task OnStreamingInputAsync(string text)
{
if (this._messageInjector is null)
{
return Task.CompletedTask;
return;
}
this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]);
this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session));
return Task.CompletedTask;
await this._messageInjector.EnqueueMessagesAsync(this._session, [new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false);
this._ux.SetQueuedMessages(await this._messageInjector.GetPendingMessagesAsync(this._session).ConfigureAwait(false));
}
/// <summary>
@@ -136,7 +135,7 @@ public sealed class HarnessAgentRunner : IDisposable
{
if (messages.Count == 0)
{
this.CompleteTurn();
await this.CompleteTurnAsync().ConfigureAwait(false);
return;
}
@@ -151,17 +150,19 @@ public sealed class HarnessAgentRunner : IDisposable
private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
{
IList<ChatMessage>? nextMessages = messages;
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? [];
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector is not null
? await this._messageInjector.GetPendingMessagesAsync(this._session).ConfigureAwait(false)
: [];
while (nextMessages is not null)
{
var runOptions = new AgentRunOptions();
foreach (var observer in this._observers)
{
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
await observer.ConfigureRunOptionsAsync(runOptions, this._agent, this._session).ConfigureAwait(false);
}
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
this._ux.BeginStreaming();
this._ux.BeginStreamingOutput();
@@ -171,7 +172,7 @@ public sealed class HarnessAgentRunner : IDisposable
{
if (this._modeProvider is not null)
{
string currentMode = this._modeProvider.GetMode(this._session);
string currentMode = await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
if (currentMode != this._ux.CurrentMode)
{
this._ux.CurrentMode = currentMode;
@@ -199,7 +200,7 @@ public sealed class HarnessAgentRunner : IDisposable
}
}
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
lastPendingMessages = await this.SyncQueuedMessageDisplayAsync(lastPendingMessages).ConfigureAwait(false);
}
}
catch (Exception ex)
@@ -208,7 +209,7 @@ public sealed class HarnessAgentRunner : IDisposable
}
// Final sync after streaming.
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
lastPendingMessages = await this.SyncQueuedMessageDisplayAsync(lastPendingMessages).ConfigureAwait(false);
this._ux.StopSpinner();
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
@@ -261,13 +262,13 @@ public sealed class HarnessAgentRunner : IDisposable
nextMessages = drained.Count > 0 ? [.. drained] : null;
}
this.CompleteTurn();
await this.CompleteTurnAsync().ConfigureAwait(false);
}
private void CompleteTurn()
private async Task CompleteTurnAsync()
{
this._ux.EndStreaming();
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
this._ux.CurrentMode = this._modeProvider is null ? null : await this._modeProvider.GetModeAsync(this._session).ConfigureAwait(false);
}
/// <summary>
@@ -275,14 +276,15 @@ public sealed class HarnessAgentRunner : IDisposable
/// Messages that have been consumed (drained by the service) are echoed to the output
/// area as regular user-input entries.
/// </summary>
private void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
/// <returns>The updated snapshot of pending messages.</returns>
private async Task<IReadOnlyList<ChatMessage>> SyncQueuedMessageDisplayAsync(IReadOnlyList<ChatMessage> lastPendingMessages)
{
if (this._messageInjector is null)
{
return;
return lastPendingMessages;
}
var pending = this._messageInjector.GetPendingMessages(this._session);
var pending = await this._messageInjector.GetPendingMessagesAsync(this._session).ConfigureAwait(false);
int consumedCount = lastPendingMessages.Count - pending.Count;
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
@@ -291,7 +293,7 @@ public sealed class HarnessAgentRunner : IDisposable
this._ux.WriteUserInputEcho(text);
}
lastPendingMessages = pending;
this._ux.SetQueuedMessages(pending);
return pending;
}
}
@@ -40,9 +40,11 @@ public static class HarnessConsole
? await options.SessionFactory(agent)
: await agent.CreateSessionAsync();
string? initialMode = modeProvider is null ? null : await modeProvider.GetModeAsync(session);
using var component = new HarnessAppComponent(
placeholder: userPrompt,
initialMode: modeProvider?.GetMode(session),
initialMode: initialMode,
inputEnabled: messageInjector is not null,
runnerFactory: ux => new HarnessAgentRunner(
agent: agent,
@@ -20,9 +20,7 @@ public abstract class ConsoleObserver
/// <param name="options">The run options to configure.</param>
/// <param name="agent">The agent being interacted with.</param>
/// <param name="session">The current agent session.</param>
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
{
}
public virtual ValueTask ConfigureRunOptionsAsync(AgentRunOptions options, AIAgent agent, AgentSession session) => default;
/// <summary>
/// Called for each <see cref="AgentResponseUpdate"/> in the response stream, regardless of
@@ -40,9 +40,9 @@ public sealed class PlanningOutputObserver : ConsoleObserver
}
/// <inheritdoc/>
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
public override async ValueTask ConfigureRunOptionsAsync(AgentRunOptions options, AIAgent agent, AgentSession session)
{
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
if (this.IsPlanningMode(await this._modeProvider.GetModeAsync(session).ConfigureAwait(false)))
{
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
}
@@ -205,7 +205,7 @@ public sealed class PlanningOutputObserver : ConsoleObserver
if (selection == ApproveOption)
{
this._modeProvider.SetMode(session, this._executionModeName);
await this._modeProvider.SetModeAsync(session, this._executionModeName).ConfigureAwait(false);
await ux.WriteInfoLineAsync(
$"✅ Switched to {this._executionModeName} mode.",
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
@@ -85,7 +85,6 @@ AIAgent agent =
MaxOutputTokens = MaxOutputTokens,
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
Path.Combine(AppContext.BaseDirectory, "agent-files")),
@@ -1,6 +1,6 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Azure AI Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
This sample demonstrates how to use a `HarnessAgent` with the Harness `AIContextProviders` (`TodoProvider` and `AgentModeProvider`) for interactive research tasks with web search capabilities powered by Microsoft Foundry. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, and context-window compaction.
Key features showcased:
@@ -19,7 +19,7 @@ Key features showcased:
Before running this sample, ensure you have:
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
1. A Microsoft Foundry project with a deployed model (e.g., `gpt-5.4`)
2. Azure CLI installed and authenticated (`az login`)
## Environment Variables
@@ -27,7 +27,7 @@ Before running this sample, ensure you have:
Set the following environment variables:
```bash
# Required: Your Azure AI Foundry OpenAI endpoint
# Required: Your Microsoft Foundry OpenAI endpoint
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
# Optional: Model deployment name (defaults to gpt-5.4)
@@ -57,7 +57,6 @@ AIAgent webSearchAgent =
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
ChatOptions = new ChatOptions
{
@@ -107,7 +106,6 @@ AIAgent parentAgent =
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
DisableToolAutoApproval = true, // If true, this disables the don't-ask-again approval functionality.
DisableWebSearch = true,
BackgroundAgents = [webSearchAgent],
@@ -32,7 +32,7 @@ A parent agent receives a list of stock tickers and uses a web-search background
## Prerequisites
- An Azure AI Foundry endpoint with an OpenAI model deployment
- A Microsoft Foundry endpoint with an OpenAI model deployment
- Set the following environment variables:
- `AZURE_FOUNDRY_OPENAI_ENDPOINT` — Your Foundry OpenAI endpoint URL
- `FOUNDRY_MODEL` — Model deployment name (defaults to `gpt-5.4`)
@@ -1,12 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a HarnessAgent with the default FileAccessProvider
// This sample demonstrates how to use a HarnessAgent with the FileAccessProvider
// to give an agent access to a folder of CSV data files. The agent can read, analyze,
// and extract information from the data, then write results back as new files.
//
// The sample includes a pre-populated `working/` folder with sales transaction data.
// The HarnessAgent's default FileAccessProvider uses `{cwd}/working` as its working directory,
// which matches this sample's folder layout.
// File access is opt-in: setting HarnessAgentOptions.FileAccessStore enables the
// FileAccessProvider, and this sample points it at the `working/` folder below the location of the executable.
// Ask the agent to analyze the data, produce summaries, or create new output files.
//
// Special commands:
@@ -1,11 +1,11 @@
# What this sample demonstrates
This sample demonstrates how to use a `HarnessAgent` with the default `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, and opt out of unused features.
This sample demonstrates how to use a `HarnessAgent` with the `FileAccessProvider` to give an agent access to a folder of data files for reading, analyzing, and writing results. The `HarnessAgent` pre-configures function invocation, per-service-call chat history persistence, in-loop compaction, tool approval, and OpenTelemetry — so the sample only needs to supply the chat client, token limits, custom instructions, a `FileAccessStore`, and opt out of unused features.
Key features showcased:
- **HarnessAgent** — a pre-configured agent that wraps a `ChatClientAgent` with function invocation, per-service-call persistence, and context-window compaction
- **FileAccessProvider**the HarnessAgent's default file access provider uses `{cwd}/working` as its working directory, matching this sample's `working/` folder
- **FileAccessProvider**file access is opt-in; setting `HarnessAgentOptions.FileAccessStore` to the sample's `working/` folder enables the provider's read/write tools
- **CSV data processing** — the agent reads sales transaction data and performs analysis on demand
- **Output file creation** — the agent can write summaries, filtered data, or reports back to the data folder
- **Streaming output** — responses are streamed token-by-token for a natural experience
@@ -15,7 +15,7 @@ Key features showcased:
Before running this sample, ensure you have:
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
1. A Microsoft Foundry project with a deployed model (e.g., `gpt-5.4`)
2. Azure CLI installed and authenticated (`az login`)
## Environment Variables
@@ -23,7 +23,7 @@ Before running this sample, ensure you have:
Set the following environment variables:
```bash
# Required: Your Azure AI Foundry OpenAI endpoint
# Required: Your Microsoft Foundry OpenAI endpoint
export AZURE_FOUNDRY_OPENAI_ENDPOINT="https://your-project.services.ai.azure.com/openai/v1/"
# Optional: Model deployment name (defaults to gpt-5.4)
@@ -51,6 +51,15 @@ You can ask the agent to:
E.g. try the following prompt `Please process the sales.csv file by first filtering it to only North region sales, and then calculating the sum of sales by person. I'd like to write the results of the processing to north_region_totals.csv`.
## ⚠️ Security: avoid tool-name collisions
This sample uses `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` to auto-approve read-only file
access tools. Built-in auto-approval rules match tool calls **solely by tool name**, so any other
registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
`file_access_grep`) would be **silently auto-approved**, bypassing the
human approval boundary. Ensure no other tool's name collides with the reserved names an
auto-approval rule approves.
## Sample Data
The included `working/sales.csv` contains sales transactions from January to March 2025 with the following columns:
@@ -82,8 +82,9 @@ var instructions =
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
// TodoProvider, AgentModeProvider, FileMemory, ToolApproval, WebSearch, and
// AgentSkillsProvider are on by default. File access is opt-in, so it is enabled here by
// supplying a FileAccessStore.
AIAgent agent =
new AIProjectClient(
new Uri(endpoint),
@@ -101,6 +102,8 @@ AIAgent agent =
OpenTelemetrySourceName = TracingSourceName,
// Point the file memory at a local folder for persistent memory across sessions.
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
// Enable file access (opt-in) by rooting the file access tools at a local working folder.
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
AIContextProviders = [codeAct],
ChatOptions = new ChatOptions
@@ -10,14 +10,14 @@ The agent can plan tasks, manage modes, store memories, read/write files, search
## Prerequisites
- .NET 10 SDK
- An Azure AI Foundry project endpoint
- A Microsoft Foundry project endpoint
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
## Environment Variables
| Variable | Description |
|----------|-------------|
| `FOUNDRY_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
| `FOUNDRY_PROJECT_ENDPOINT` | Your Microsoft Foundry project endpoint |
| `FOUNDRY_MODEL` | Model deployment name (default: `gpt-5.4`) |
## Running
@@ -174,9 +174,9 @@ async Task ApprovalLoopAsync()
{
AutoApprovalRules =
[
functionCall =>
context =>
{
Console.WriteLine($" Auto-approving: {functionCall.Name}");
Console.WriteLine($" Auto-approving: {context.FunctionCallContent.Name}");
return ValueTask.FromResult(true);
},
],
@@ -252,7 +252,6 @@ AIAgent CreateLeanHarnessAgent(
DisableAgentModeProvider = true,
DisableTodoProvider = disableTodoProvider,
DisableFileMemory = true,
DisableFileAccess = true,
DisableWebSearch = true,
ToolApprovalAgentOptions = toolApprovalAgentOptions,
ChatOptions = new ChatOptions
@@ -32,7 +32,7 @@ The Python sample in [microsoft/agent-framework#6174](https://github.com/microso
Before running this sample, ensure you have:
1. An Azure AI Foundry project with a deployed model (e.g., `gpt-5.4`)
1. A Microsoft Foundry project with a deployed model (e.g., `gpt-5.4`)
2. Azure CLI installed and authenticated (`az login`)
## Environment Variables
@@ -40,7 +40,7 @@ Before running this sample, ensure you have:
Set the following environment variables:
```bash
# Required: Your Azure AI Foundry project endpoint
# Required: Your Microsoft Foundry project endpoint
export AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
# Optional: Model deployment name (defaults to gpt-5.4)
@@ -19,6 +19,7 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A
| --- | --- |
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
| [Claw_Step02_WorkingWithData](./BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md) | Post 2 — file access, approvals, and durable memory (file memory plus optional Foundry memory) |
| [Claw_Step03_ScalingCapabilities](./BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md) | Post 3 — scaling the claw with skills (plus optional Foundry skills), a confined shell, CodeAct, and background agents |
## Security Considerations
@@ -31,7 +31,7 @@ public static class Program
{
private static async Task Main()
{
// Set up the Azure AI Foundry client
// Set up the Microsoft Foundry client
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
@@ -92,7 +92,7 @@ public static class Program
string model)
{
ProjectsAgentVersion agentVersion = await aiProjectClient.AgentAdministrationClient.CreateAgentVersionAsync(
$"{targetLanguage} Translator",
$"{targetLanguage}Translator",
new ProjectsAgentVersionCreationOptions(
new DeclarativeAgentDefinition(model: model)
{
@@ -37,7 +37,7 @@ public static class Program
{
private static async Task Main()
{
// Set up the Azure AI Foundry client
// Set up the Microsoft Foundry client
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
@@ -34,7 +34,7 @@ public static class Program
{
private static async Task Main()
{
// Set up the Azure AI Foundry client
// Set up the Microsoft Foundry client
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
@@ -35,7 +35,7 @@ public static class Program
{
private static async Task Main()
{
// Set up the Azure AI Foundry client
// Set up the Microsoft Foundry client
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
@@ -37,7 +37,7 @@ public static class Program
private static async Task Main()
{
// Set up the Azure AI Foundry client
// Set up the Microsoft Foundry client
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
@@ -70,7 +70,7 @@ public static class Program
using var traceProvider = traceProviderBuilder.Build();
// Set up the Azure AI Foundry client
// Set up the Microsoft Foundry client
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
@@ -18,7 +18,7 @@ namespace WorkflowMagenticOrchestrationSample;
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - An Azure AI Foundry project endpoint and model deployment must be configured.
/// - A Microsoft Foundry project endpoint and model deployment must be configured.
/// - Run <c>az login</c> before executing the sample.
/// </remarks>
public static class Program

Some files were not shown because too many files have changed in this diff Show More