mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e7dcab50c | ||
|
|
70507e9a44 |
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue author and check their team membership.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.github - Octokit REST client from actions/github-script
|
||||
* @param {object} opts.context - GitHub Actions context
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue 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;
|
||||
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;
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
core.setFailed('Could not determine issue author (user may be deleted).');
|
||||
return { author: null, isTeamMember: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.teams.getByName({
|
||||
org: context.repo.owner,
|
||||
team_slug: teamSlug,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: teamSlug,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Author ${author} is not a member of team ${teamSlug}.`);
|
||||
isTeamMember = false;
|
||||
} else {
|
||||
core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { author, isTeamMember };
|
||||
}
|
||||
|
||||
module.exports = checkTeamMembership;
|
||||
@@ -1,178 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for check_team_membership.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_check_team_membership.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
info(msg) { this._infoMessages.push(msg); },
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const context = {
|
||||
payload: { issue: payloadIssue },
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
const github = {
|
||||
rest: {
|
||||
issues: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
data: { state: teamState },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { core, context, github };
|
||||
}
|
||||
|
||||
const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Author resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('author resolution', () => {
|
||||
it('resolves author from event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'payload-user' } },
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'payload-user');
|
||||
});
|
||||
|
||||
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 });
|
||||
assert.equal(result.author, 'api-user');
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue user is null (deleted account)', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: null },
|
||||
apiUser: 'fetched-user',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'fetched-user');
|
||||
});
|
||||
|
||||
it('handles deleted account when API also returns null user', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: null });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, null);
|
||||
assert.equal(result.isTeamMember, false);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('deleted')));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('team lookup', () => {
|
||||
it('fails the job when team lookup errors', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const error = new Error('Bad credentials');
|
||||
github.rest.teams.getByName = async () => { throw error; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === error,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed')));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team membership
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('team membership', () => {
|
||||
it('returns true for active team member', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'member' } },
|
||||
teamState: 'active',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, true);
|
||||
});
|
||||
|
||||
it('returns false for pending team member', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'pending-user' } },
|
||||
teamState: 'pending',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, false);
|
||||
});
|
||||
|
||||
it('treats 404 membership response as non-member without failing', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'outsider' } },
|
||||
});
|
||||
const notFoundError = new Error('Not Found');
|
||||
notFoundError.status = 404;
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; };
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, false);
|
||||
assert.equal(core._failedMessages.length, 0);
|
||||
assert.ok(core._infoMessages.some(m => m.includes('not a member')));
|
||||
});
|
||||
|
||||
it('fails the job on non-404 membership errors', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const serverError = new Error('Internal Server Error');
|
||||
serverError.status = 500;
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === serverError,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
|
||||
});
|
||||
|
||||
it('fails the job on membership errors without status code', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const networkError = new Error('ECONNREFUSED');
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === networkError,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
name: DevFlow PR Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Pull request number to review
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
pr_number: ${{ steps.pr.outputs.pr_number }}
|
||||
pr_url: ${{ steps.pr.outputs.pr_url }}
|
||||
repo: ${{ steps.pr.outputs.repo }}
|
||||
steps:
|
||||
- name: Resolve PR metadata
|
||||
id: pr
|
||||
shell: bash
|
||||
env:
|
||||
PR_HTML_URL: ${{ github.event.pull_request.html_url }}
|
||||
PR_NUMBER_EVENT: ${{ github.event.pull_request.number }}
|
||||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
pr_number="${PR_NUMBER_EVENT}"
|
||||
pr_url="${PR_HTML_URL}"
|
||||
else
|
||||
pr_number="${PR_NUMBER_INPUT}"
|
||||
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
|
||||
fi
|
||||
|
||||
if [[ ! "$pr_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Could not determine PR number; for workflow_dispatch runs, the 'pr_number' input is required when not running on pull_request_target." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
let author = context.payload.pull_request?.user?.login;
|
||||
if (!author) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(process.env.PR_NUMBER),
|
||||
});
|
||||
author = pr.user.login;
|
||||
}
|
||||
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: process.env.TEAM_NAME,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
console.log(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
isTeamMember = false;
|
||||
}
|
||||
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; proceeding with review.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
|
||||
}
|
||||
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
|
||||
timeout-minutes: 60
|
||||
# Advisory check: failures here should not block the PR. The reviewer
|
||||
# posts comments as a best-effort signal; if the pipeline breaks, the
|
||||
# PR author should still be able to merge without a red required check.
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
# Safe checkout: base repo only, not the untrusted PR head.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
path: target-repo
|
||||
|
||||
# Private DevFlow checkout: the PAT/token grants access to this repo's code.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
token: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
- name: Install DevFlow dependencies
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run PR review
|
||||
id: review
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
PR_URL: ${{ needs.team_check.outputs.pr_url }}
|
||||
run: |
|
||||
uv run python scripts/trigger_pr_review.py \
|
||||
--pr-url "$PR_URL" \
|
||||
--github-username "$GITHUB_ACTOR" \
|
||||
--no-require-comment-selection
|
||||
@@ -1,199 +0,0 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: Issue number to triage
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||
repo: ${{ steps.issue.outputs.repo }}
|
||||
steps:
|
||||
- name: Resolve issue metadata
|
||||
id: issue
|
||||
shell: bash
|
||||
env:
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
||||
issue_number="${ISSUE_NUMBER_EVENT}"
|
||||
else
|
||||
issue_number="${ISSUE_NUMBER_INPUT}"
|
||||
fi
|
||||
|
||||
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check issue author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; skipping auto-triage.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a team member; proceeding with triage.`);
|
||||
}
|
||||
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
path: target-repo
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
token: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
- name: Install DevFlow dependencies
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Classify issue relevance
|
||||
id: spam
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
|
||||
run: |
|
||||
uv run python scripts/classify_issue_spam.py \
|
||||
--repo "$ISSUE_REPO" \
|
||||
--issue-number "$ISSUE_NUMBER" \
|
||||
--repo-path "${TARGET_REPO_PATH}" \
|
||||
--apply-labels
|
||||
|
||||
- name: Stop after spam gate
|
||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
||||
shell: bash
|
||||
env:
|
||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
||||
run: |
|
||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
||||
exit 1
|
||||
|
||||
- name: Reproduce reported issue
|
||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
|
||||
# Model-provider settings for generated repro code. Never enter the
|
||||
# agent prompt; consumed by SDK constructors via os.environ. Azure
|
||||
# OpenAI and Foundry auth via AAD from the azure/login step above.
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
|
||||
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
run: |
|
||||
uv run python scripts/trigger_issue_repro.py \
|
||||
--repo "$ISSUE_REPO" \
|
||||
--issue-number "$ISSUE_NUMBER" \
|
||||
--github-username "$GITHUB_ACTOR"
|
||||
@@ -87,14 +87,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
@@ -138,14 +130,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Misc integration tests (Anthropic, Hyperlight, Ollama, MCP)
|
||||
python-tests-misc-integration:
|
||||
@@ -189,14 +173,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 30
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
- name: Stop local MCP server
|
||||
if: always()
|
||||
shell: bash
|
||||
@@ -273,14 +249,6 @@ jobs:
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry integration tests
|
||||
python-tests-foundry:
|
||||
@@ -327,14 +295,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
@@ -379,80 +339,7 @@ jobs:
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs:
|
||||
[
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore flaky report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
flaky-report-history-integration-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
../test-results/
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flaky-test-report
|
||||
path: |
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
|
||||
@@ -181,13 +181,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
@@ -251,13 +244,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Azure OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Misc integration tests (Anthropic, Ollama, MCP)
|
||||
python-tests-misc-integration:
|
||||
@@ -335,13 +321,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Misc integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
@@ -413,13 +392,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Functions integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
@@ -437,10 +409,6 @@ jobs:
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
|
||||
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
|
||||
FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -480,13 +448,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
@@ -536,7 +497,7 @@ jobs:
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
@@ -547,76 +508,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Cosmos integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs:
|
||||
[
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore flaky report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
flaky-report-history-merge-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
../test-results/
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flaky-test-report
|
||||
path: |
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
|
||||
@@ -136,10 +136,6 @@ celerybeat.pid
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Foundry agent CLI (contains secrets, auto-generated)
|
||||
.foundry-agent.json
|
||||
.foundry-agent-build.log
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
@@ -207,8 +203,6 @@ temp*/
|
||||
|
||||
# AI
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
WARP.md
|
||||
**/memory-bank/
|
||||
**/projectBrief.md
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: evmattso
|
||||
date: 2026-04-10
|
||||
deciders: evmattso
|
||||
---
|
||||
|
||||
# Foundry Toolbox Support in FoundryChatClient
|
||||
|
||||
## 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.
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
agent = Agent(client=client, instructions="...", tools=toolbox)
|
||||
```
|
||||
|
||||
**Success metric:** an agent can consume a toolbox with no manual handling of version-resolution logic on the user's side.
|
||||
|
||||
## What is the problem being solved?
|
||||
|
||||
`azure-ai-projects==2.1.0a20260409002` ships a new `BetaToolboxesOperations` surface, reachable as `AIProjectClient.beta.toolboxes` on the raw SDK client (and therefore as `FoundryChatClient.project_client.beta.toolboxes` through our wrapper), that lets teams:
|
||||
- Group related hosted tools (code interpreter, file search, MCP, web search, etc.) under a named toolbox
|
||||
- Version toolboxes immutably, so agents can pin to a specific configuration for production stability
|
||||
- Share toolboxes across multiple agents in a project
|
||||
|
||||
However, consuming a toolbox from the framework today requires:
|
||||
1. Knowing the raw SDK accessor path (`client.project_client.beta.toolboxes`)
|
||||
2. Making two calls for the common case — `.get(name)` to find the default version, then `.get_version(name, version)` to actually retrieve tools
|
||||
3. Manually unpacking `toolbox.tools` before passing them to `Agent(tools=...)`
|
||||
|
||||
None of this is hard, but it's the kind of boilerplate that should live in the client. Every other hosted tool in `FoundryChatClient` (code interpreter, file search, web search, image generation, MCP) already has a factory method (`get_code_interpreter_tool()`, etc.). Toolbox support should fit the same shape on the chat-client composition surface.
|
||||
|
||||
## API Changes
|
||||
|
||||
### One new method on the FoundryChatClient surface
|
||||
|
||||
The public toolbox-consumption surface lands on:
|
||||
|
||||
- `RawFoundryChatClient` (inherited by `FoundryChatClient`) in `_chat_client.py`
|
||||
|
||||
The implementation delegates to shared helper functions in `_tools.py` so there is a single source of truth for the SDK calls.
|
||||
|
||||
**Scope note:** `FoundryAgent` is intentionally not part of this design. `FoundryAgent` is the runtime surface for invoking an already-configured server-side Foundry agent; if that agent should use a toolbox, the toolbox/tools should already be configured on the Foundry side (UI or `azure-ai-projects` authoring flow) before MAF connects to it.
|
||||
|
||||
**Scope note:** Authoring a server-side agent whose definition references a toolbox (via `PromptAgentDefinition(tools=toolbox.tools, ...)` + `client.agents.create_version(...)`) is deliberately outside MAF scope. That is an `azure-ai-projects` / service-resource authoring concern, not a future MAF feature. Users who need it should use the raw Azure SDK directly.
|
||||
|
||||
```python
|
||||
async def get_toolbox(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
) -> ToolboxVersionObject:
|
||||
"""Fetch a Foundry toolbox by name.
|
||||
|
||||
If ``version`` is ``None``, resolves the toolbox's current default version
|
||||
(two requests). If ``version`` is specified, fetches that version directly
|
||||
(single request).
|
||||
|
||||
:param name: The name of the toolbox.
|
||||
:param version: Optional immutable version identifier to pin to.
|
||||
:return: A ``ToolboxVersionObject``. Pass its ``tools`` attribute to
|
||||
``Agent(tools=toolbox.tools)``.
|
||||
:raises azure.core.exceptions.ResourceNotFoundError: If the toolbox or
|
||||
version does not exist.
|
||||
"""
|
||||
|
||||
```
|
||||
|
||||
### Return types: raw SDK models, no custom wrappers
|
||||
|
||||
Methods return the `azure.ai.projects.models` types directly:
|
||||
|
||||
- `get_toolbox()` → `ToolboxVersionObject` (has `.name`, `.version`, `.tools`, `.id`, `.created_at`, `.description`, `.metadata`, `.policies`)
|
||||
|
||||
No custom wrapper classes are defined. Returning the SDK types directly:
|
||||
- Eliminates maintenance overhead of keeping a custom wrapper aligned with SDK changes
|
||||
- Matches the existing convention — `get_code_interpreter_tool()` returns the raw `CodeInterpreterTool` SDK type
|
||||
- Means any new fields the SDK adds to these types flow through automatically
|
||||
|
||||
`Agent(..., tools=...)` will accept the fetched toolbox object directly by flattening to `toolbox.tools` internally.
|
||||
|
||||
### Design decisions
|
||||
|
||||
**Instance methods, not `@staticmethod` factories.** Existing `get_code_interpreter_tool()` / `get_mcp_tool()` / etc. are `@staticmethod` because they're pure factories with no network I/O. Toolbox fetching requires the project client, so these new methods must be instance methods. This is a deliberate departure from the existing-factory pattern, justified by the async-with-I/O nature of the operation.
|
||||
|
||||
**Raw SDK type passthrough (no custom wrappers).** There is only one toolbox type in the Foundry SDK and maintaining a shadow wrapper would create alignment risk as the SDK evolves. The raw `ToolboxVersionObject` and `ToolboxObject` carry all the fields users need. Individual tools inside `toolbox.tools` are the same `azure.ai.projects.models.Tool` subclasses returned by other factory methods.
|
||||
|
||||
**Two-request default-version path.** When `version=None`, implementation calls `.get(name)` to find `default_version`, then `.get_version(name, default_version)` for the tools. Caching the default-version mapping was considered and rejected — default versions can change server-side via `update(default_version=...)`, and a stale cache would silently give callers the wrong tools. Two requests at agent setup is acceptable.
|
||||
|
||||
**No discovery/listing surface in MAF.** Discovery is intentionally left to the raw `azure-ai-projects` client. MAF does not currently expose project-resource listing surfaces for many other Foundry resources (deployments, vector stores, agents, etc.), so the toolbox design stays narrowly focused on explicit retrieval by name/version.
|
||||
|
||||
**Shared helpers in `_tools.py`.** The SDK-call helper function (`fetch_toolbox`) lives in a shared module so the chat-client surface stays thin and the request logic remains centralized.
|
||||
|
||||
**`tools=toolbox` convenience, not a new wrapper type.** Although `get_toolbox()` returns the raw `ToolboxVersionObject`, Agent Framework can still support `tools=toolbox` / `tools=[toolbox]` by flattening the toolbox's `.tools` internally. That matches existing SDK ergonomics where some higher-level objects can be placed directly in `tools=` and unpacked underneath, without introducing a public `FoundryToolbox` wrapper.
|
||||
|
||||
**Errors pass through unchanged.** `ResourceNotFoundError`, `HttpResponseError`, etc. from the SDK propagate as-is. No framework-specific exception hierarchy.
|
||||
|
||||
## E2E Code Samples
|
||||
|
||||
### Primary sample
|
||||
|
||||
New file: `samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
print(f"Loaded toolbox {toolbox.name}@{toolbox.version} ({len(toolbox.tools)} tools)")
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="You are a research assistant.",
|
||||
tools=toolbox,
|
||||
)
|
||||
|
||||
result = await agent.run("What are the latest developments in quantum error correction?")
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Version pinning
|
||||
|
||||
```python
|
||||
toolbox = await client.get_toolbox("research_tools", version="v3")
|
||||
```
|
||||
|
||||
### Combining multiple toolboxes
|
||||
|
||||
```python
|
||||
toolbox_a = await client.get_toolbox("research_tools")
|
||||
toolbox_b = await client.get_toolbox("some_other_tools", version="v3")
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="...",
|
||||
tools=[toolbox_a, toolbox_b],
|
||||
)
|
||||
```
|
||||
|
||||
### Combining toolbox tools with locally defined tools
|
||||
|
||||
```python
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
|
||||
def get_internal_metrics(metric_name: str) -> dict:
|
||||
"""Custom tool that reads from an internal dashboard."""
|
||||
...
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="...",
|
||||
tools=[get_internal_metrics, toolbox],
|
||||
)
|
||||
```
|
||||
|
||||
### Selecting only some tools from a toolbox
|
||||
|
||||
Developers will not always want to pass the entire toolbox through unchanged. A
|
||||
small helper in the Foundry package provides local post-fetch selection without
|
||||
changing the raw return type of `get_toolbox()`.
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import select_toolbox_tools
|
||||
|
||||
toolbox = await client.get_toolbox("research_tools")
|
||||
|
||||
selected_tools = select_toolbox_tools(
|
||||
toolbox,
|
||||
include_names=["githubmcp", "code_interpreter"],
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions="Use only the selected toolbox tools.",
|
||||
tools=selected_tools,
|
||||
)
|
||||
```
|
||||
|
||||
Supported filters:
|
||||
|
||||
```python
|
||||
from agent_framework.foundry import FoundryHostedToolType, select_toolbox_tools
|
||||
|
||||
selected_tools = select_toolbox_tools(
|
||||
toolbox,
|
||||
include_types=["mcp", "code_interpreter"], # type: Collection[FoundryHostedToolType]
|
||||
exclude_names=["internal_admin_tool"],
|
||||
)
|
||||
```
|
||||
|
||||
Helper signature:
|
||||
|
||||
```python
|
||||
type FoundryHostedToolType = Literal[
|
||||
"code_interpreter",
|
||||
"file_search",
|
||||
"image_generation",
|
||||
"mcp",
|
||||
"web_search",
|
||||
] | str
|
||||
|
||||
def select_toolbox_tools(
|
||||
tools: ToolboxVersionObject | Sequence[Tool | dict[str, Any]],
|
||||
*,
|
||||
include_names: Collection[str] | None = None,
|
||||
exclude_names: Collection[str] | None = None,
|
||||
include_types: Collection[FoundryHostedToolType] | None = None,
|
||||
exclude_types: Collection[FoundryHostedToolType] | None = None,
|
||||
predicate: Callable[[Tool | dict[str, Any]], bool] | None = None,
|
||||
) -> list[Tool | dict[str, Any]]:
|
||||
...
|
||||
```
|
||||
|
||||
Normalized name precedence for `include_names` / `exclude_names`:
|
||||
|
||||
1. MCP `server_label`
|
||||
2. generic tool `name`
|
||||
3. fallback tool `type`
|
||||
|
||||
This keeps `get_toolbox()` as a thin fetch API and makes selection an explicit,
|
||||
local post-processing step, while still allowing the ergonomic
|
||||
`select_toolbox_tools(toolbox, ...)` call shape.
|
||||
|
||||
## Native vs MCP consumption of a Foundry toolbox
|
||||
|
||||
A Foundry toolbox can be consumed two ways. This design adds new implementation work only for the first:
|
||||
|
||||
1. **Native consumption (in scope).** Tools execute inside Foundry's agent runtime. `get_toolbox()` returns the `ToolboxVersionObject` whose `.tools` attribute carries typed tool configs that the runtime interprets server-side. This design is specifically for `FoundryChatClient`-backed local agent composition.
|
||||
|
||||
2. **MCP consumption (already supported through existing MCP abstractions).** A Foundry toolbox can also be exposed as an MCP server. In that case, use the existing `MCPStreamableHTTPTool(name=..., url=...)` — it already handles this path with any chat client (Foundry, OpenAI, Anthropic, etc.). No new Foundry-specific API is needed for MCP-exposed toolboxes in this design.
|
||||
|
||||
### MCPStreamableHTTPTool example for a Foundry toolbox endpoint
|
||||
|
||||
If Foundry gives you an MCP endpoint for the toolbox (for example from the
|
||||
toolbox details UI / endpoint surface), the existing MCP client path is:
|
||||
|
||||
```python
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
toolbox_mcp = MCPStreamableHTTPTool(
|
||||
name="research_tools",
|
||||
url="https://<foundry-toolbox-mcp-endpoint>",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
instructions="You are a research assistant.",
|
||||
tools=[toolbox_mcp],
|
||||
)
|
||||
```
|
||||
|
||||
This is a different integration shape than `get_toolbox(...).tools`:
|
||||
|
||||
- `get_toolbox(...).tools` = **native Foundry hosted-tool configs** interpreted by the
|
||||
Foundry runtime
|
||||
- `MCPStreamableHTTPTool(name=..., url=...)` = **live MCP server connection** to a
|
||||
toolbox endpoint
|
||||
|
||||
The design in this spec adds first-class support only for the native hosted-tool
|
||||
path. The MCP path is already served by the framework's existing MCP abstractions.
|
||||
|
||||
These paths are not unified because they have fundamentally different execution models. Native toolbox tools are declarative configs the Foundry runtime executes; MCP consumption is a live wire protocol to a running server.
|
||||
|
||||
**MCP authentication inside a toolbox** is handled server-side via `project_connection_id` on individual `MCPTool` entries (OAuth connection objects configured in the Foundry project). The client never holds bearer tokens. Consent flow handling (`CONSENT_REQUIRED` → user-visible consent URL) happens during `agent.run()`, not during toolbox fetching — see Non-goals.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Unit tests in `packages/foundry/tests/test_toolbox.py` with mocked `project_client.beta.toolboxes`. A single opt-in live round-trip, `test_integration_get_toolbox_round_trip_against_real_project`, is marked `@pytest.mark.integration`; it is skipped by default and only runs when the required Foundry credentials are available.
|
||||
|
||||
Coverage:
|
||||
|
||||
- `get_toolbox(name, version="v3")` — explicit version, single request. Assert `.get` not called, `.get_version` awaited once, returns `ToolboxVersionObject`.
|
||||
- `get_toolbox(name)` — default-version resolution. Assert `.get` then `.get_version` called in order with correct args.
|
||||
- Error propagation — `ResourceNotFoundError` from `.get` propagates unchanged.
|
||||
- Tool passthrough — heterogeneous tool list (`CodeInterpreterTool`, `MCPTool(project_connection_id=...)`) passes through unchanged. Asserts `project_connection_id` survives.
|
||||
- Agent integration smoke — `tools=toolbox` / `tools=[toolbox]` flatten to the underlying toolbox tools.
|
||||
- Multiple toolbox composition smoke — `tools=[toolbox_a, toolbox_b]` flattens into a single agent tool list.
|
||||
- `get_toolbox_tool_name()` — selection-name precedence is MCP `server_label`, then `name`, then `type`.
|
||||
- `select_toolbox_tools(toolbox, include_names=...)` — selects by normalized tool names directly from a fetched toolbox object.
|
||||
- `select_toolbox_tools(toolbox, include_types=...)` — selects by tool types with `Literal`-guided IDE completion.
|
||||
- `select_toolbox_tools(..., exclude_names=..., predicate=...)` — supports exclusion + custom predicates.
|
||||
|
||||
Deliberately **not** covered:
|
||||
- Runtime consent-flow handling for OAuth MCP tools (see Non-goals).
|
||||
- Toolbox discovery/listing (`list_toolboxes`, `list_toolbox_versions`) — deliberately left to the raw Azure SDK.
|
||||
- Full CRUD (`create_version`, `update`, `delete`) and server-side agent authoring — see Non-goals.
|
||||
|
||||
Live Foundry API integration is exercised only through the opt-in `@pytest.mark.integration` round-trip noted above; it is not part of the default test run.
|
||||
|
||||
## Framework dependency: `normalize_tools` flattening
|
||||
|
||||
The core `normalize_tools` function in `packages/core/agent_framework/_tools.py` already supports flattening composite tool inputs. Toolbox support extends that behavior so a fetched `ToolboxVersionObject` is treated as a composite tool source and flattened to its `.tools`.
|
||||
|
||||
That enables:
|
||||
|
||||
- `tools=toolbox`
|
||||
- `tools=[toolbox]`
|
||||
- `tools=[local_tool, toolbox]`
|
||||
- `tools=[toolbox_a, toolbox_b]`
|
||||
|
||||
while still keeping `select_toolbox_tools(toolbox.tools, ...)` available for partial selection before the final agent construction step.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Telemetry for toolbox support has two separate goals:
|
||||
|
||||
1. **Observe toolbox API access** — `get_toolbox()`
|
||||
2. **Observe toolbox usage during agent runs** — when users pass toolbox-derived tools into `Agent(..., tools=...)`
|
||||
|
||||
### Request telemetry for toolbox API access
|
||||
|
||||
When Agent Framework constructs the `AIProjectClient` internally for `FoundryChatClient`, it already sets:
|
||||
|
||||
```python
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT
|
||||
```
|
||||
|
||||
That means toolbox API requests made through:
|
||||
|
||||
- `project_client.beta.toolboxes.get(...)`
|
||||
- `project_client.beta.toolboxes.get_version(...)`
|
||||
|
||||
carry the standard MAF user-agent marker and can be queried in backend request logs the same way as other Foundry SDK calls made through framework-owned clients.
|
||||
|
||||
Important constraint: if the caller passes an already-constructed `project_client`, Agent Framework does **not** mutate it to inject the MAF user-agent. In that case, toolbox API request telemetry reflects whatever user-agent behavior that external client was configured with.
|
||||
|
||||
### Runtime telemetry for toolbox usage on agent runs
|
||||
|
||||
Tool-level telemetry already captures which hosted Foundry tools are available / invoked during agent execution. The remaining gap is **toolbox provenance**: once the user writes `tools=toolbox` (or otherwise flattens the toolbox into tool configs), the framework sees only raw tool configs and no longer knows which toolbox name/version supplied them.
|
||||
|
||||
The design for closing the **client-side** observability gap is **internal provenance tracking**, not user-supplied metadata and not a new public wrapper type.
|
||||
|
||||
#### Provenance model
|
||||
|
||||
Note: this section is still under investigation.
|
||||
|
||||
When `get_toolbox()` or `list_toolbox_versions()` returns a `ToolboxVersionObject`, Agent Framework will attach private provenance metadata to:
|
||||
|
||||
- the returned toolbox object
|
||||
- each tool inside `toolbox.tools`
|
||||
|
||||
Recommended shape (private, internal-only):
|
||||
|
||||
```python
|
||||
tool._maf_toolbox_sources = [
|
||||
{
|
||||
"id": toolbox.id,
|
||||
"name": toolbox.name,
|
||||
"version": toolbox.version,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Key properties of this approach:
|
||||
|
||||
- **No new public API surface** — users still work with raw `ToolboxVersionObject` / `ToolboxObject`
|
||||
- **No user burden** — callers do not need to stamp metadata manually
|
||||
- **Provenance follows the tool objects** — works with:
|
||||
- `tools=toolbox.tools`
|
||||
- `tools=[toolbox_a.tools, toolbox_b.tools]`
|
||||
- `tools=[*toolbox_a.tools, *toolbox_b.tools]`
|
||||
- **Private attributes are not serialized** into the actual request payload sent to the model/service, so this metadata does not leak into the tool definition body
|
||||
|
||||
This is intentionally preferred over introducing a new public `FoundryToolbox` wrapper purely for telemetry, and preferred over a separate global provenance registry. The provenance lives on the existing tool objects so list-copying and chat-option merging naturally preserve it.
|
||||
|
||||
#### Span enrichment
|
||||
|
||||
When Agent / chat telemetry computes span attributes for a run, it should inspect the final tool list and aggregate the private toolbox provenance from any tool objects that carry it. The aggregated values are then emitted as attributes on the existing run/chat spans.
|
||||
|
||||
Suggested custom attributes:
|
||||
|
||||
- `agent_framework.foundry.toolbox.ids`
|
||||
- `agent_framework.foundry.toolbox.names`
|
||||
- `agent_framework.foundry.toolbox.versions`
|
||||
- or a single compact attribute such as `agent_framework.foundry.toolbox.sources=["research_tools@1","some_other_tools@3"]`
|
||||
|
||||
The single compact `toolbox.sources` form is preferred for initial implementation because it is easy to query and easy to render from combined tool lists.
|
||||
|
||||
#### Scope of telemetry changes
|
||||
|
||||
This design does **not** require new spans. It enriches existing telemetry:
|
||||
|
||||
- toolbox API access continues to rely on request logs + Azure SDK distributed tracing + MAF user-agent
|
||||
- agent/chat execution spans gain toolbox provenance attributes when toolbox-derived tools are present
|
||||
|
||||
Implementation-wise, this design most likely touches:
|
||||
|
||||
- `packages/foundry/agent_framework_foundry/_tools.py` — to stamp provenance on fetched toolbox objects / tools
|
||||
- `packages/core/agent_framework/observability.py` — to aggregate provenance into span attributes
|
||||
|
||||
#### Important limitation: no server-side toolbox telemetry solution yet
|
||||
|
||||
Private provenance attached to tool objects is only useful on the client side. It
|
||||
does **not** go over the wire to the Foundry service because those private fields
|
||||
are intentionally not serialized into the request payload.
|
||||
|
||||
That means this design can support:
|
||||
|
||||
- local OpenTelemetry / exporter spans emitted by Agent Framework
|
||||
- local attribution of a run to one or more fetched toolboxes
|
||||
|
||||
but it does **not** solve:
|
||||
|
||||
- server-side request-log attribution of a model/tool run back to a toolbox
|
||||
- backend/database queries that need the service itself to know "this tool came from toolbox X"
|
||||
|
||||
At the moment, we do not have a satisfactory design for server-side toolbox
|
||||
telemetry. The service would require additional structured information on the
|
||||
request, and there is no accepted mechanism in this design yet for projecting
|
||||
toolbox provenance into a server-visible field/header/metadata shape.
|
||||
|
||||
So the telemetry story in this spec is explicitly limited to **client-side
|
||||
toolbox telemetry**. Server-side toolbox attribution remains an open question and
|
||||
requires either:
|
||||
|
||||
- new service/API support, or
|
||||
- a later framework design for emitting additional server-visible request metadata.
|
||||
|
||||
#### Deliberate non-goals for telemetry
|
||||
|
||||
- No requirement for users to pass explicit toolbox metadata in `default_options["metadata"]` or `run(..., options=...)`
|
||||
- No new public `FoundryToolbox` wrapper type just to preserve attribution
|
||||
- No attempted server-side attribution mechanism in this design (for example a custom request header or request metadata field) until there is a validated end-to-end contract for it
|
||||
|
||||
## Non-goals / Future Work
|
||||
|
||||
Explicitly out of scope for this design. Each is a separate design and PR when needed.
|
||||
|
||||
1. **Create/update/delete toolboxes from code.** CRUD is rare in agent consumption flows. Users who need it drop to `client.project_client.beta.toolboxes.create_version(...)`, `.update(...)`, `.delete(...)` directly.
|
||||
|
||||
2. **Server-side agent authoring from toolbox.** Creating a `PromptAgentDefinition(tools=toolbox.tools)` + `client.agents.create_version(...)` is a future feature covering agent authoring from code. The toolbox read API provides the building blocks; the authoring helpers are a separate design.
|
||||
|
||||
3. **OAuth consent-flow runtime handling.** When a toolbox contains MCP tools with `project_connection_id` pointing to an OAuth connection, the runtime may return `CONSENT_REQUIRED` mid-run. This is a runtime concern separate from toolbox fetching.
|
||||
|
||||
4. **Live integration tests.** This PR ships unit tests only.
|
||||
|
||||
5. **Toolbox caching or refresh APIs.** Each `get_toolbox()` call hits the network. Users who want caching wrap the call themselves.
|
||||
+1
-8
@@ -402,11 +402,4 @@ FodyWeavers.xsd
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
# Foundry agent CLI config (contains secrets, auto-generated)
|
||||
.foundry-agent.json
|
||||
.foundry-agent-build.log
|
||||
|
||||
# Pre-published output for Docker builds
|
||||
out/
|
||||
*.sln.iml
|
||||
@@ -22,16 +22,11 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.53.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.20.0" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Google Gemini -->
|
||||
<PackageVersion Include="Google.GenAI" Version="1.6.0" />
|
||||
<PackageVersion Include="Mscc.GenerativeAI.Microsoft" Version="2.9.3" />
|
||||
@@ -42,29 +37,29 @@
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.4" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
@@ -104,8 +99,8 @@
|
||||
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.3.171-beta" />
|
||||
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.3.171-beta" />
|
||||
<!-- A2A -->
|
||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A" Version="0.3.4-preview" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Inference SDKs -->
|
||||
@@ -188,4 +183,4 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<Solution>
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<BuildType Name="Debug" />
|
||||
<BuildType Name="Publish" />
|
||||
<BuildType Name="Release" />
|
||||
</Configurations>
|
||||
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/">
|
||||
<File Path="samples/AGENTS.md" />
|
||||
<File Path="samples/README.md" />
|
||||
@@ -64,7 +67,6 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -160,7 +162,6 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
@@ -282,44 +283,7 @@
|
||||
<Folder Name="/Samples/03-workflows/Evaluation/">
|
||||
<Project Path="samples/03-workflows/Evaluation/Evaluation_WorkflowEval/Evaluation_WorkflowEval.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/">
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/" />
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableAgents/AzureFunctions/">
|
||||
<File Path="samples/04-hosting/DurableAgents/AzureFunctions/.editorconfig" />
|
||||
@@ -343,13 +307,11 @@
|
||||
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/A2A/">
|
||||
<File Path="samples/02-agents/A2A/README.md" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/A2A/">
|
||||
<File Path="samples/04-hosting/A2A/README.md" />
|
||||
<Project Path="samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
@@ -376,6 +338,15 @@
|
||||
<Project Path="samples/05-end-to-end/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
<Project Path="samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
|
||||
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
|
||||
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
|
||||
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/README.md" />
|
||||
@@ -532,19 +503,18 @@
|
||||
<File Path="tests/Directory.Build.props" />
|
||||
</Folder>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CosmosNoSql/Microsoft.Agents.AI.CosmosNoSql.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
|
||||
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
@@ -566,10 +536,11 @@
|
||||
<Folder Name="/Tests/IntegrationTests/">
|
||||
<Project Path="tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
||||
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
|
||||
@@ -586,11 +557,12 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
|
||||
"src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj",
|
||||
"src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj",
|
||||
"src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj",
|
||||
"src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj",
|
||||
"src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
@@ -9,4 +9,4 @@
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
</configuration>
|
||||
@@ -1,14 +1,13 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
<VersionPrefix>1.1.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260423</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260410.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260410.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.3.0</GitTag>
|
||||
<GitTag>1.1.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
@@ -30,8 +29,7 @@
|
||||
|
||||
<!-- Report low, moderate, high and critical advisories -->
|
||||
<NuGetAuditLevel>low</NuGetAuditLevel>
|
||||
|
||||
|
||||
|
||||
<!-- Default description and tags. Packages can override. -->
|
||||
<Authors>Microsoft</Authors>
|
||||
<Company>Microsoft</Company>
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when
|
||||
// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings.
|
||||
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
|
||||
|
||||
// Initialize an A2ACardResolver to get an A2A agent card.
|
||||
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
|
||||
|
||||
// Get the agent card
|
||||
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
|
||||
|
||||
// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding.
|
||||
// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card
|
||||
// advertises multiple supported interfaces.
|
||||
A2AClientOptions options = new()
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.HttpJson]
|
||||
};
|
||||
|
||||
// To prefer JSON-RPC instead, use:
|
||||
// A2AClientOptions options = new()
|
||||
// {
|
||||
// PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
// };
|
||||
|
||||
// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding.
|
||||
AIAgent agent = agentCard.AsAIAgent(options: options);
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
|
||||
Console.WriteLine(response);
|
||||
@@ -1,27 +0,0 @@
|
||||
# A2A Agent Protocol Selection
|
||||
|
||||
This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card.
|
||||
|
||||
A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used.
|
||||
|
||||
The sample:
|
||||
|
||||
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
|
||||
- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding
|
||||
- Creates an `AIAgent` from the resolved agent card using the specified binding
|
||||
- Sends a message to the agent and displays the response
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10.0 SDK or later
|
||||
- An A2A agent server running and accessible via HTTP
|
||||
|
||||
**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md
|
||||
|
||||
Set the following environment variable:
|
||||
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
|
||||
```
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens,
|
||||
// allowing recovery from stream interruptions without losing progress.
|
||||
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
|
||||
|
||||
// Initialize an A2ACardResolver to get an A2A agent card.
|
||||
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
|
||||
|
||||
// Get the agent card
|
||||
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
|
||||
|
||||
// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
|
||||
AIAgent agent = agentCard.AsAIAgent();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
ResponseContinuationToken? continuationToken = null;
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session))
|
||||
{
|
||||
// Saving the continuation token to be able to reconnect to the same response stream later.
|
||||
// Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent
|
||||
// returns a message instead of a task, the continuation token will not be initialized.
|
||||
// A2A agents do not support stream resumption from a specific point in the stream,
|
||||
// but only reconnection to obtain the same response stream from the beginning.
|
||||
// So, A2A agents will return an initialized continuation token in the first update
|
||||
// representing the beginning of the stream, and it will be null in all subsequent updates.
|
||||
if (update.ContinuationToken is { } token)
|
||||
{
|
||||
continuationToken = token;
|
||||
}
|
||||
|
||||
// Imitating stream interruption
|
||||
break;
|
||||
}
|
||||
|
||||
// Reconnect to the same response stream using the continuation token obtained from the previous run.
|
||||
// As a first update, the agent will return an update representing the current state of the response at the moment of calling
|
||||
// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached.
|
||||
if (continuationToken is not null)
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken }))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
Console.WriteLine(update.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
# A2A Agent Stream Reconnection
|
||||
|
||||
This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress.
|
||||
|
||||
The sample:
|
||||
|
||||
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
|
||||
- Sends a request to the agent and begins streaming the response
|
||||
- Captures a continuation token from the stream for later reconnection
|
||||
- Simulates a stream interruption by breaking out of the streaming loop
|
||||
- Reconnects to the same response stream using the captured continuation token
|
||||
- Displays the response received after reconnection
|
||||
|
||||
This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing.
|
||||
|
||||
> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable.
|
||||
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10.0 SDK or later
|
||||
- An A2A agent server running and accessible via HTTP
|
||||
|
||||
Set the following environment variable:
|
||||
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
|
||||
```
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,281 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to dynamically expand the set of function tools available to an
|
||||
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
|
||||
// When the model calls RequestTools with a description of the capabilities needed, the function
|
||||
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
|
||||
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
// Pre-defined tool implementations that can be loaded on demand.
|
||||
[Description("Get the current weather for a city.")]
|
||||
static string GetWeather([Description("The city name.")] string city) =>
|
||||
city.ToUpperInvariant() switch
|
||||
{
|
||||
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
|
||||
"NEW YORK" => "New York: 72°F, sunny and warm.",
|
||||
"LONDON" => "London: 48°F, overcast with fog.",
|
||||
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
|
||||
};
|
||||
|
||||
[Description("Get the current local time for a city.")]
|
||||
static string GetTime([Description("The city name.")] string city) =>
|
||||
city.ToUpperInvariant() switch
|
||||
{
|
||||
"SEATTLE" => "Seattle: 9:00 AM PST",
|
||||
"NEW YORK" => "New York: 12:00 PM EST",
|
||||
"LONDON" => "London: 5:00 PM GMT",
|
||||
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
|
||||
};
|
||||
|
||||
[Description("Convert a temperature from Fahrenheit to Celsius.")]
|
||||
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
|
||||
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
|
||||
|
||||
// A registry of tool sets that can be loaded by description keyword.
|
||||
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
|
||||
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
|
||||
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
|
||||
};
|
||||
|
||||
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
|
||||
AIFunction requestToolsFunction = AIFunctionFactory.Create(
|
||||
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
|
||||
"Call this when you need capabilities that are not yet available in your current tool set.")] (
|
||||
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
|
||||
) =>
|
||||
{
|
||||
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
|
||||
var context = FunctionInvokingChatClient.CurrentContext
|
||||
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
|
||||
|
||||
var tools = context.Options?.Tools;
|
||||
if (tools is null)
|
||||
{
|
||||
return "Unable to register new tools: ChatOptions.Tools is not available.";
|
||||
}
|
||||
|
||||
// Find matching tool sets from the catalog.
|
||||
List<string> addedToolNames = [];
|
||||
foreach (var kvp in toolCatalog)
|
||||
{
|
||||
var keyword = kvp.Key;
|
||||
var catalogTools = kvp.Value;
|
||||
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var tool in catalogTools)
|
||||
{
|
||||
// Avoid adding duplicates.
|
||||
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
|
||||
{
|
||||
tools.Add(tool);
|
||||
addedToolNames.Add(fn.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addedToolNames.Count > 0
|
||||
? "Successfully loaded tools"
|
||||
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
|
||||
},
|
||||
name: "RequestTools");
|
||||
|
||||
// Create the agent with only the RequestTools function initially.
|
||||
// Insert chat client middleware that logs the tools available on each LLM call,
|
||||
// making the dynamic expansion visible in the console output.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
|
||||
.BuildAIAgent(
|
||||
instructions: """
|
||||
You are a helpful assistant. You start with limited tools.
|
||||
When you need functionality that you don't currently have, call RequestTools with a description
|
||||
of what you need. After new tools are loaded, use them to answer the user's question.
|
||||
""",
|
||||
tools: [requestToolsFunction]);
|
||||
|
||||
// Run a conversation that triggers dynamic tool expansion.
|
||||
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
|
||||
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the weather like in Seattle and London?",
|
||||
"What time is it in New York?",
|
||||
"Can you convert those temperatures to Celsius?"
|
||||
];
|
||||
|
||||
// --- Non-Streaming Mode ---
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("=== Non-Streaming Mode ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
foreach (var prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
|
||||
var response = await agent.RunAsync(prompt, session);
|
||||
|
||||
// Print all message contents including tool calls, tool results, and text.
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case FunctionCallContent functionCall:
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(textContent.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// --- Streaming Mode ---
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("=== Streaming Mode ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
AgentSession streamingSession = await agent.CreateSessionAsync();
|
||||
|
||||
foreach (var prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
|
||||
bool inAgentText = false;
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case FunctionCallContent functionCall:
|
||||
if (inAgentText)
|
||||
{
|
||||
Console.WriteLine();
|
||||
inAgentText = false;
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
|
||||
if (!inAgentText)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("[Agent] ");
|
||||
Console.ResetColor();
|
||||
inAgentText = true;
|
||||
}
|
||||
|
||||
Console.Write(textContent.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inAgentText)
|
||||
{
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Chat client middleware that logs the number and names of tools on each LLM request.
|
||||
async Task<ChatResponse> ToolLoggingMiddleware(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
IChatClient innerChatClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LogTools(options);
|
||||
|
||||
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Streaming version of the tool logging middleware.
|
||||
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
IChatClient innerChatClient,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
LogTools(options);
|
||||
|
||||
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared helper to log the current tool set.
|
||||
void LogTools(ChatOptions? options)
|
||||
{
|
||||
if (options?.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine(" [Middleware] LLM call with 0 tools");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# Dynamic Function Tools
|
||||
|
||||
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- The agent starts with only a single `RequestTools` function
|
||||
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
|
||||
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
|
||||
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
|
||||
|
||||
## How it works
|
||||
|
||||
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
|
||||
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
|
||||
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -46,7 +46,6 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|
||||
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
||||
// process does not invoke tools locally.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
||||
|
||||
// Replace with your own Foundry toolbox name.
|
||||
const string ToolboxName = "research_toolbox";
|
||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
||||
const string SecondToolboxName = "analysis_toolbox";
|
||||
// Replace with any question that exercises the tools configured in your toolbox.
|
||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
await Main(projectClient, model, endpoint);
|
||||
// await CombineToolboxes(projectClient, model, endpoint);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main: single toolbox
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
|
||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
||||
tools: tools.ToList());
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alternative: combine tools from multiple toolboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
||||
|
||||
// Comment out if the toolboxes already exist in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
||||
|
||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
||||
|
||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
||||
tools: allTools);
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
{
|
||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
// script, not the application itself. This helper exists so the sample can
|
||||
// be run end-to-end without first setting a toolbox up by hand.
|
||||
|
||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||
var options = new AgentAdministrationClientOptions();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
options);
|
||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist — nothing to delete.
|
||||
}
|
||||
|
||||
// Create a fresh version with a single MCP tool.
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: "api-specs",
|
||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
var created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [mcpTool],
|
||||
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
# Agent_Step25_ToolboxServerSideTools
|
||||
|
||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
||||
|
||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
||||
process does not invoke tools locally.
|
||||
|
||||
This is the dotnet equivalent of the Python sample:
|
||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
||||
|
||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
||||
an existing toolbox unchanged.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
||||
Foundry project API (resolving the default version if none is specified)
|
||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
||||
API request as server-side tool definitions
|
||||
|
||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
||||
|
||||
## Sample flows
|
||||
|
||||
| Flow | Description |
|
||||
|------|-------------|
|
||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
||||
|
||||
Uncomment the desired flow in the top-level statements to try each one.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
@@ -19,4 +19,3 @@ The getting started samples demonstrate the fundamental concepts and functionali
|
||||
| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files |
|
||||
| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients |
|
||||
| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development |
|
||||
| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features |
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
**/Properties/launchSettings.json
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
+1
-5
@@ -18,12 +18,8 @@ AIAgent agent = agentCard.AsAIAgent();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// AllowBackgroundResponses must be true so the server returns immediately with a continuation token
|
||||
// instead of blocking until the task is complete.
|
||||
AgentRunOptions options = new() { AllowBackgroundResponses = true };
|
||||
|
||||
// Start the initial run with a long-running task.
|
||||
AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options);
|
||||
AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session);
|
||||
|
||||
// Poll until the response is complete.
|
||||
while (response.ContinuationToken is { } token)
|
||||
@@ -3,7 +3,7 @@
|
||||
These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework.
|
||||
|
||||
For other samples that demonstrate how to use AIAgent instances,
|
||||
see the [Getting Started With Agents](../Agents/README.md) samples.
|
||||
see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -15,8 +15,6 @@ See the README.md for each sample for the prerequisites for that sample.
|
||||
|---|---|
|
||||
|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.|
|
||||
|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.|
|
||||
|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.|
|
||||
|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Abstractions source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent .
|
||||
# docker run --rm -p 8088:8088 hosted-invocations-echo-agent
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"]
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="AIAgent"/> that echoes the user's input text back as the response.
|
||||
/// No LLM or external service is required.
|
||||
/// </summary>
|
||||
public sealed class EchoAIAgent : AIAgent
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override string Name => "echo-agent";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Description => "An agent that echoes back the input message.";
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inputText = GetInputText(messages);
|
||||
var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"Echo: {inputText}"));
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inputText = GetInputText(messages);
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = [new TextContent($"Echo: {inputText}")],
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new EchoAgentSession());
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(new EchoAgentSession());
|
||||
|
||||
private static string GetInputText(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Role == ChatRole.User)
|
||||
{
|
||||
return message.Text ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal session for the echo agent. No state is persisted.
|
||||
/// </summary>
|
||||
private sealed class EchoAgentSession : AgentSession;
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Invocations;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace HostedInvocationsEchoAgent;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="InvocationHandler"/> that reads the request body as plain text,
|
||||
/// passes it to the <see cref="EchoAIAgent"/>, and writes the response back.
|
||||
/// </summary>
|
||||
public sealed class EchoInvocationHandler(EchoAIAgent agent) : InvocationHandler
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task HandleAsync(
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
InvocationContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Read the raw text from the request body.
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var input = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
// Run the echo agent with the input text.
|
||||
var agentResponse = await agent.RunAsync(input, cancellationToken: cancellationToken);
|
||||
|
||||
// Write the agent response text back to the HTTP response.
|
||||
response.ContentType = "text/plain";
|
||||
await response.WriteAsync(agentResponse.Text, cancellationToken);
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedInvocationsEchoAgent</RootNamespace>
|
||||
<AssemblyName>HostedInvocationsEchoAgent</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Invocations" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Abstractions" Version="1.0.0" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.Invocations" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Invocations;
|
||||
using DotNetEnv;
|
||||
using HostedInvocationsEchoAgent;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the echo agent as a singleton (no LLM needed).
|
||||
builder.Services.AddSingleton<EchoAIAgent>();
|
||||
|
||||
// Register the Invocations SDK services and wire the handler.
|
||||
builder.Services.AddInvocationsServer();
|
||||
builder.Services.AddScoped<InvocationHandler, EchoInvocationHandler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map the Invocations protocol endpoints:
|
||||
// POST /invocations — invoke the agent
|
||||
// GET /invocations/{id} — get result (not used by this sample)
|
||||
// POST /invocations/{id}/cancel — cancel (not used by this sample)
|
||||
app.MapInvocationsServer();
|
||||
|
||||
app.Run();
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
# Hosted-Invocations-EchoAgent
|
||||
|
||||
A minimal echo agent hosted as a Foundry Hosted Agent using the **Invocations protocol**. The agent reads the request body as plain text, passes it through a custom `EchoAIAgent`, and writes the echoed text back in the response. No LLM or Azure credentials are required.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/invocations \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Hello, world!"
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```
|
||||
Echo: Hello, world!
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8088:8088 hosted-invocations-echo-agent
|
||||
```
|
||||
|
||||
### 4. Test it
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/invocations \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Hello from Docker!"
|
||||
```
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-invocations-echo-agent
|
||||
displayName: "Hosted Invocations Echo Agent"
|
||||
|
||||
description: >
|
||||
A minimal echo agent hosted as a Foundry Hosted Agent using the Invocations
|
||||
protocol. Reads the request body as plain text, echoes it back in the response.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Invocations Protocol
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-invocations-echo-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: invocations
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-invocations-echo-agent
|
||||
protocols:
|
||||
- protocol: invocations
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIAgent"/> that invokes a remote agent hosted with the Invocations protocol
|
||||
/// by sending plain-text HTTP POST requests to the <c>/invocations</c> endpoint.
|
||||
/// </summary>
|
||||
public sealed class InvocationsAIAgent : AIAgent
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly Uri _invocationsUri;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvocationsAIAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The base URI of the hosted agent (e.g., <c>http://localhost:8089</c>).
|
||||
/// The <c>/invocations</c> path is appended automatically.
|
||||
/// </param>
|
||||
/// <param name="httpClient">Optional <see cref="HttpClient"/> to use. If <see langword="null"/>, a new instance is created.</param>
|
||||
/// <param name="name">Optional name for the agent.</param>
|
||||
/// <param name="description">Optional description for the agent.</param>
|
||||
public InvocationsAIAgent(
|
||||
Uri agentEndpoint,
|
||||
HttpClient? httpClient = null,
|
||||
string? name = null,
|
||||
string? description = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentEndpoint);
|
||||
|
||||
this._httpClient = httpClient ?? new HttpClient();
|
||||
|
||||
// Ensure the base URI ends with a slash so that combining works correctly.
|
||||
var baseUri = agentEndpoint.AbsoluteUri.EndsWith('/')
|
||||
? agentEndpoint
|
||||
: new Uri(agentEndpoint.AbsoluteUri + "/");
|
||||
this._invocationsUri = new Uri(baseUri, "invocations");
|
||||
|
||||
this.Name = name ?? "invocations-agent";
|
||||
this.Description = description ?? "An agent that calls a remote Invocations protocol endpoint.";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var inputText = GetLastUserText(messages);
|
||||
var responseText = await this.SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false);
|
||||
return new AgentResponse(new ChatMessage(ChatRole.Assistant, responseText));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The Invocations protocol returns a complete response (no SSE streaming),
|
||||
// so we yield a single update with the full text.
|
||||
var inputText = GetLastUserText(messages);
|
||||
var responseText = await this.SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = [new TextContent(responseText)],
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new InvocationsAgentSession());
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions));
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(new InvocationsAgentSession());
|
||||
|
||||
private async Task<string> SendInvocationAsync(string input, CancellationToken cancellationToken)
|
||||
{
|
||||
using var content = new StringContent(input, System.Text.Encoding.UTF8, "text/plain");
|
||||
using var response = await this._httpClient.PostAsync(this._invocationsUri, content, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string GetLastUserText(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
string? lastUserText = null;
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Role == ChatRole.User)
|
||||
{
|
||||
lastUserText = message.Text;
|
||||
}
|
||||
}
|
||||
|
||||
return lastUserText ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal session for the invocations agent. No state is persisted.
|
||||
/// </summary>
|
||||
private sealed class InvocationsAgentSession : AgentSession;
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
|
||||
?? "http://localhost:8088");
|
||||
|
||||
// Create an agent that calls the remote Invocations endpoint.
|
||||
InvocationsAIAgent agent = new(agentEndpoint);
|
||||
|
||||
// REPL
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Simple Invocations Agent Sample
|
||||
Connected to: {agentEndpoint}
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
""");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("You> ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? input = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input)) { continue; }
|
||||
if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; }
|
||||
|
||||
try
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("Agent> ");
|
||||
Console.ResetColor();
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync(input))
|
||||
{
|
||||
Console.Write(update);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Goodbye!");
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>SimpleInvocationsAgentClient</RootNamespace>
|
||||
<AssemblyName>simple-invocations-agent-client</AssemblyName>
|
||||
<NoWarn>$(NoWarn);NU1605</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AGENT_NAME=hosted-chat-client-agent
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-chat-client-agent .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-chat-client-agent --env-file .env hosted-chat-client-agent
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"]
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedChatClientAgent</RootNamespace>
|
||||
<AssemblyName>HostedChatClientAgent</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Create the agent via the AI project client using the Responses API.
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: """
|
||||
You are a helpful AI assistant hosted as a Foundry Hosted Agent.
|
||||
You can help with a wide range of tasks including answering questions,
|
||||
providing explanations, brainstorming ideas, and offering guidance.
|
||||
Be concise, clear, and helpful in your responses.
|
||||
""",
|
||||
name: agentName,
|
||||
description: "A simple general-purpose AI assistant");
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# Hosted-ChatClientAgent
|
||||
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Or with curl (specifying the agent name explicitly):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-chat-client-agent .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-chat-client-agent \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-chat-client-agent
|
||||
```
|
||||
|
||||
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Or with curl (specifying the agent name explicitly):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-chat-client-agent
|
||||
displayName: "Hosted Chat Client Agent"
|
||||
|
||||
description: >
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent
|
||||
using the Agent Framework instance hosting pattern.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-chat-client-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-chat-client-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AGENT_NAME=<your-foundry-agent-name>
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedFoundryAgent.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-foundry-agent .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=<your-agent> -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-foundry-agent
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedFoundryAgent.dll"]
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedFoundryAgent</RootNamespace>
|
||||
<AssemblyName>HostedFoundryAgent</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
var aiProjectClient = new AIProjectClient(projectEndpoint, credential);
|
||||
|
||||
// Retrieve the Foundry-managed agent by name (latest version).
|
||||
ProjectsAgentRecord agentRecord = await aiProjectClient
|
||||
.AgentAdministrationClient.GetAgentAsync(agentName);
|
||||
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord);
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
///
|
||||
/// When debugging and testing a hosted agent in a local Docker container, Azure CLI
|
||||
/// and other interactive credentials are not available. This credential reads a
|
||||
/// pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
///
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
/// In production, the Foundry platform injects a managed identity automatically.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.GetAccessToken();
|
||||
}
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AccessToken>(this.GetAccessToken());
|
||||
}
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
# Hosted-FoundryAgent
|
||||
|
||||
A hosted agent that delegates to a **Foundry-managed agent definition**. Instead of defining the model, instructions, and tools inline in code, this sample retrieves an existing agent registered in the Foundry platform via `AIProjectClient.AsAIAgent(agentRecord)` and hosts it using the Responses protocol.
|
||||
|
||||
This is the **Foundry hosting** pattern — the agent's behavior is configured in the platform (via Foundry UI, CLI, or API), and this server simply wraps and serves it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a **registered agent** (created via Foundry UI, CLI, or API)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
You also need to set `AGENT_NAME` — the name of the Foundry-managed agent to host. This is injected automatically by the Foundry platform when deployed. For local development, pass it as an environment variable.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent
|
||||
AGENT_NAME=<your-agent-name> dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Or with curl (specifying the agent name explicitly):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hello!", "model": "<your-agent-name>"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-foundry-agent .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=<your-agent-name> \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-foundry-agent
|
||||
```
|
||||
|
||||
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Or with curl (specifying the agent name explicitly):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hello!", "model": "<your-agent-name>"}'
|
||||
```
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## How it differs from Hosted-ChatClientAgent
|
||||
|
||||
| | Hosted-ChatClientAgent | Hosted-FoundryAgent |
|
||||
|---|---|---|
|
||||
| **Agent definition** | Inline in code (`AsAIAgent(model, instructions)`) | Managed in Foundry platform (`AsAIAgent(agentRecord)`) |
|
||||
| **Model/instructions** | Set in `Program.cs` | Set in Foundry UI/CLI/API |
|
||||
| **Tools** | Defined in code | Configured in the platform |
|
||||
| **Use case** | Full control over agent behavior | Platform-managed agent with centralized config |
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-foundry-agent
|
||||
displayName: "Hosted Foundry Agent"
|
||||
|
||||
description: >
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent,
|
||||
backed by a Foundry-managed agent definition.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-foundry-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-foundry-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,5 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedLocalTools.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-local-tools .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-local-tools -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-local-tools
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedLocalTools.dll"]
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedLocalTools</RootNamespace>
|
||||
<AssemblyName>HostedLocalTools</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -1,164 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Seattle Hotel Agent - A hosted agent with local C# function tools.
|
||||
// Demonstrates how to define and wire local tools that the LLM can invoke,
|
||||
// a key advantage of code-based hosted agents over prompt agents.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Hotel data ───────────────────────────────────────────────────────────────
|
||||
|
||||
Hotel[] seattleHotels =
|
||||
[
|
||||
new("Contoso Suites", 189, 4.5, "Downtown"),
|
||||
new("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
|
||||
new("Alpine Ski House", 249, 4.7, "Seattle Center"),
|
||||
new("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
|
||||
new("Northwind Inn", 139, 4.0, "Capitol Hill"),
|
||||
new("Relecloud Hotel", 99, 3.8, "University District"),
|
||||
];
|
||||
|
||||
// ── Tool: GetAvailableHotels ─────────────────────────────────────────────────
|
||||
|
||||
[Description("Get available hotels in Seattle for the specified dates.")]
|
||||
string GetAvailableHotels(
|
||||
[Description("Check-in date in YYYY-MM-DD format")] string checkInDate,
|
||||
[Description("Check-out date in YYYY-MM-DD format")] string checkOutDate,
|
||||
[Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500)
|
||||
{
|
||||
if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn))
|
||||
{
|
||||
return "Error parsing check-in date. Please use YYYY-MM-DD format.";
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut))
|
||||
{
|
||||
return "Error parsing check-out date. Please use YYYY-MM-DD format.";
|
||||
}
|
||||
|
||||
if (checkOut <= checkIn)
|
||||
{
|
||||
return "Error: Check-out date must be after check-in date.";
|
||||
}
|
||||
|
||||
int nights = (checkOut - checkIn).Days;
|
||||
List<Hotel> availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
|
||||
|
||||
if (availableHotels.Count == 0)
|
||||
{
|
||||
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
|
||||
}
|
||||
|
||||
StringBuilder result = new();
|
||||
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
|
||||
result.AppendLine();
|
||||
|
||||
foreach (Hotel hotel in availableHotels)
|
||||
{
|
||||
int totalCost = hotel.PricePerNight * nights;
|
||||
result.AppendLine($"**{hotel.Name}**");
|
||||
result.AppendLine($" Location: {hotel.Location}");
|
||||
result.AppendLine($" Rating: {hotel.Rating}/5");
|
||||
result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})");
|
||||
result.AppendLine();
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
// ── Create and host the agent ────────────────────────────────────────────────
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
|
||||
|
||||
When a user asks about hotels in Seattle:
|
||||
1. Ask for their check-in and check-out dates if not provided
|
||||
2. Ask about their budget preferences if not mentioned
|
||||
3. Use the GetAvailableHotels tool to find available options
|
||||
4. Present the results in a friendly, informative way
|
||||
5. Offer to help with additional questions about the hotels or Seattle
|
||||
|
||||
Be conversational and helpful. If users ask about things outside of Seattle hotels,
|
||||
politely let them know you specialize in Seattle hotel recommendations.
|
||||
""",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-local-tools",
|
||||
description: "Seattle hotel search agent with local function tools",
|
||||
tools: [AIFunctionFactory.Create(GetAvailableHotels)]);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
# Hosted-LocalTools
|
||||
|
||||
A hosted agent with **local C# function tools** for hotel search. Demonstrates how to define and wire local tools that the LLM can invoke — a key advantage of code-based hosted agents over prompt agents.
|
||||
|
||||
The agent specializes in finding hotels in Seattle, with a `GetAvailableHotels` tool that searches a mock hotel database by dates and budget.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools
|
||||
AGENT_NAME=hosted-local-tools dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Find me a hotel in Seattle for Dec 20-25 under $200/night"
|
||||
```
|
||||
|
||||
Or with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Find me a hotel in Seattle for Dec 20-25 under $200/night", "model": "hosted-local-tools"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-local-tools .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-local-tools \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-local-tools
|
||||
```
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What hotels are available in Seattle for next weekend?"
|
||||
```
|
||||
|
||||
## How local tools work
|
||||
|
||||
The agent has a single tool `GetAvailableHotels` defined as a C# method with `[Description]` attributes. The LLM decides when to call it based on the user's request:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `checkInDate` | string | Check-in date (YYYY-MM-DD) |
|
||||
| `checkOutDate` | string | Check-out date (YYYY-MM-DD) |
|
||||
| `maxPrice` | int | Max price per night in USD (default: 500) |
|
||||
|
||||
The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative.
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-local-tools
|
||||
displayName: "Seattle Hotel Agent with Local Tools"
|
||||
|
||||
description: >
|
||||
A travel assistant agent that helps users find hotels in Seattle.
|
||||
Demonstrates local C# tool execution — a key advantage of code-based
|
||||
hosted agents over prompt agents.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Local Tools
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-local-tools
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-local-tools
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,5 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedMcpTools.dll"]
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local source, which means a standard
|
||||
# multi-stage Docker build cannot resolve dependencies outside this folder.
|
||||
# Pre-publish the app targeting the container runtime and copy the output:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-mcp-tools .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=mcp-tools -e GITHUB_PAT=$GITHUB_PAT -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-mcp-tools
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedMcpTools.dll"]
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedMcpTools</RootNamespace>
|
||||
<AssemblyName>HostedMcpTools</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates a hosted agent with two layers of MCP (Model Context Protocol) tools:
|
||||
//
|
||||
// 1. CLIENT-SIDE MCP: The agent connects to the Microsoft Learn MCP server directly via
|
||||
// McpClient, discovers tools, and handles tool invocations locally within the agent process.
|
||||
//
|
||||
// 2. SERVER-SIDE MCP: The agent declares a HostedMcpServerTool for the same MCP server which
|
||||
// delegates tool discovery and invocation to the LLM provider (Azure OpenAI Responses API).
|
||||
// The provider calls the MCP server on behalf of the agent — no local connection needed.
|
||||
//
|
||||
// Both patterns use the Microsoft Learn MCP server to illustrate the architectural difference:
|
||||
// client-side tools are resolved and invoked by the agent, while server-side tools are resolved
|
||||
// and invoked by the LLM provider.
|
||||
|
||||
#pragma warning disable MEAI001 // HostedMcpServerTool is experimental
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."));
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Client-side MCP: Microsoft Learn (local resolution) ──────────────────────
|
||||
// Connect directly to the MCP server. The agent discovers and invokes tools locally.
|
||||
Console.WriteLine("Connecting to Microsoft Learn MCP server (client-side)...");
|
||||
|
||||
await using var learnMcp = await McpClient.CreateAsync(new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
Name = "Microsoft Learn (client)",
|
||||
}));
|
||||
|
||||
var clientTools = await learnMcp.ListToolsAsync();
|
||||
Console.WriteLine($"Client-side MCP tools: {string.Join(", ", clientTools.Select(t => t.Name))}");
|
||||
|
||||
// ── Server-side MCP: Microsoft Learn (provider resolution) ───────────────────
|
||||
// Declare a HostedMcpServerTool — the LLM provider (Responses API) handles tool
|
||||
// invocations directly. No local MCP connection needed for this pattern.
|
||||
AITool serverTool = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn_hosted",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
|
||||
};
|
||||
Console.WriteLine("Server-side MCP tool: microsoft_docs_search (via HostedMcpServerTool)");
|
||||
|
||||
// ── Combine both tool types into a single agent ──────────────────────────────
|
||||
// The agent has access to tools from both MCP patterns simultaneously.
|
||||
List<AITool> allTools = [.. clientTools.Cast<AITool>(), serverTool];
|
||||
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: """
|
||||
You are a helpful developer assistant with access to Microsoft Learn documentation.
|
||||
Use the available tools to search and retrieve documentation.
|
||||
Be concise and provide direct answers with relevant links.
|
||||
""",
|
||||
name: "mcp-tools",
|
||||
description: "Developer assistant with dual-layer MCP tools (client-side and server-side)",
|
||||
tools: allTools);
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// In Development, also map the OpenAI-compatible route that AIProjectClient uses.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
# Hosted-McpTools
|
||||
|
||||
A hosted agent demonstrating **two layers of MCP (Model Context Protocol) tool integration**:
|
||||
|
||||
1. **Client-side MCP (Microsoft Learn)** — The agent connects directly to the Microsoft Learn MCP server via `McpClient`, discovers tools, and handles tool invocations locally within the agent process.
|
||||
|
||||
2. **Server-side MCP (Microsoft Learn)** — The agent declares a `HostedMcpServerTool` which delegates tool discovery and invocation to the LLM provider (Azure OpenAI Responses API). The provider calls the MCP server on behalf of the agent with no local connection needed.
|
||||
|
||||
## How the two MCP patterns differ
|
||||
|
||||
| | Client-side MCP | Server-side MCP |
|
||||
|---|---|---|
|
||||
| **Connection** | Agent connects to MCP server directly | LLM provider connects to MCP server |
|
||||
| **Tool invocation** | Handled by the agent process | Handled by the Responses API |
|
||||
| **Auth** | Agent manages credentials | Provider manages credentials |
|
||||
| **Use case** | Custom/private MCP servers, fine-grained control | Public MCP servers, simpler setup |
|
||||
| **Example** | Microsoft Learn (`McpClient` + `HttpClientTransport`) | Microsoft Learn (`HostedMcpServerTool`) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
```
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
# Uses GitHub MCP (client-side)
|
||||
azd ai agent invoke --local "Search for the agent-framework repository on GitHub"
|
||||
|
||||
# Uses Microsoft Learn MCP (server-side)
|
||||
azd ai agent invoke --local "How do I create an Azure storage account using az cli?"
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
### 1. Publish for the container runtime
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build and run
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-mcp-tools .
|
||||
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=mcp-tools \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-mcp-tools
|
||||
```
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: mcp-tools
|
||||
displayName: "MCP Tools Agent"
|
||||
|
||||
description: >
|
||||
A developer assistant demonstrating dual-layer MCP integration:
|
||||
client-side GitHub MCP tools handled by the agent and server-side
|
||||
Microsoft Learn MCP tools delegated to the LLM provider.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- MCP
|
||||
- Model Context Protocol
|
||||
|
||||
template:
|
||||
name: mcp-tools
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: mcp-tools
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,5 +0,0 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedTextRag.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-text-rag .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-text-rag -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-text-rag
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedTextRag.dll"]
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedTextRag</RootNamespace>
|
||||
<AssemblyName>HostedTextRag</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
|
||||
// capabilities to a hosted agent. The provider runs a search against an external knowledge base
|
||||
// before each model invocation and injects the results into the model context.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 6,
|
||||
};
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-text-rag",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
},
|
||||
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Mock search function ─────────────────────────────────────────────────────
|
||||
// In production, replace this with a real search provider (e.g., Azure AI Search).
|
||||
|
||||
static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
List<TextSearchProvider.TextSearchResult> results = [];
|
||||
|
||||
if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable.
|
||||
/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(GetAccessToken());
|
||||
|
||||
private static AccessToken GetAccessToken()
|
||||
{
|
||||
var token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
if (string.IsNullOrEmpty(token) || token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
# Hosted-TextRag
|
||||
|
||||
A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities using `TextSearchProvider`. The agent grounds its answers in product documentation by running a search before each model invocation, then citing the source in its response.
|
||||
|
||||
This sample demonstrates how to add knowledge grounding to a hosted agent without requiring an external search index — using a mock search function that can be replaced with Azure AI Search or any other provider.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Azure AI Foundry project endpoint:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_BEARER_TOKEN=
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag
|
||||
AGENT_NAME=hosted-text-rag dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What is your return policy?"
|
||||
azd ai agent invoke --local "How long does shipping take?"
|
||||
azd ai agent invoke --local "How do I clean my tent?"
|
||||
```
|
||||
|
||||
Or with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What is your return policy?", "model": "hosted-text-rag"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-text-rag .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-text-rag \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-text-rag
|
||||
```
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What is your return policy?"
|
||||
```
|
||||
|
||||
## How RAG works in this sample
|
||||
|
||||
The `TextSearchProvider` runs a mock search **before each model invocation**:
|
||||
|
||||
| User query contains | Search result injected |
|
||||
|---|---|
|
||||
| "return" or "refund" | Contoso Outdoors Return Policy |
|
||||
| "shipping" | Contoso Outdoors Shipping Guide |
|
||||
| "tent" or "fabric" | TrailRunner Tent Care Instructions |
|
||||
|
||||
The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative.
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-text-rag
|
||||
displayName: "Hosted Text RAG Agent"
|
||||
|
||||
description: >
|
||||
A support specialist agent for Contoso Outdoors with RAG capabilities.
|
||||
Uses TextSearchProvider to ground answers in product documentation
|
||||
before each model invocation.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- RAG
|
||||
- Text Search
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-text-rag
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -1,9 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-text-rag
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolbox</RootNamespace>
|
||||
<AssemblyName>HostedToolbox</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
|
||||
//
|
||||
// Demonstrates how to register one or more Foundry toolsets so the agent can
|
||||
// call tools provided by the Foundry platform's managed MCP proxy.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
|
||||
// (injected automatically by Foundry platform at runtime)
|
||||
//
|
||||
// Optional:
|
||||
// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── Create agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolset.
|
||||
Use the available tools to answer user questions.
|
||||
If a tool is not available for a request, let the user know clearly.
|
||||
""",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
|
||||
description: "Hosted agent backed by Foundry Toolset MCP tools");
|
||||
|
||||
// ── Build the host ────────────────────────────────────────────────────────────
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the agent and response handler
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.MaxValue);
|
||||
}
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
AZURE_OPENAI_ENDPOINT=https://<your-account>.openai.azure.com/
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedWorkflowHandoff.dll"]
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-workflow-handoff .
|
||||
# docker run --rm -p 8088:8088 -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-workflow-handoff
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedWorkflowHandoff.dll"]
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>HostedWorkflowHandoff</RootNamespace>
|
||||
<AssemblyName>HostedWorkflowHandoff</AssemblyName>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);NU1605;MAAIW001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
-470
@@ -1,470 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <summary>
|
||||
/// Static HTML pages served by the sample application.
|
||||
/// </summary>
|
||||
internal static class Pages
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Homepage
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string Home = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Foundry Responses Hosting — Demos</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
|
||||
main { width: 100%; max-width: 700px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: .5rem; color: #1a1a1a; }
|
||||
.subtitle { color: #555; margin-bottom: 2rem; line-height: 1.5; }
|
||||
.cards { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 1.5rem; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
|
||||
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.1); transform: translateY(-2px); }
|
||||
.card h2 { font-size: 1.15rem; color: #0066cc; margin-bottom: .4rem; }
|
||||
.card p { color: #555; line-height: 1.5; font-size: .9rem; }
|
||||
.card .tags { margin-top: .6rem; display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
.card .tag { background: #e8f0fe; color: #1a73e8; padding: .15rem .5rem; border-radius: 12px; font-size: .75rem; }
|
||||
footer { margin-top: 2rem; font-size: .8rem; color: #999; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>🚀 Foundry Responses Hosting</h1>
|
||||
<p class="subtitle">
|
||||
Agent-framework agents hosted via the Azure AI Responses Server SDK.<br/>
|
||||
Each demo registers a different agent and serves it through <code>POST /responses</code>.
|
||||
</p>
|
||||
<div class="cards">
|
||||
<a class="card" href="/tool-demo">
|
||||
<h2>🔧 Tool Demo</h2>
|
||||
<p>An agent with local function tools (time, weather) and remote MCP tools from
|
||||
Microsoft Learn for documentation search.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Local Tools</span>
|
||||
<span class="tag">MCP</span>
|
||||
<span class="tag">Microsoft Learn</span>
|
||||
<span class="tag">Streaming</span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="/workflow-demo">
|
||||
<h2>🔀 Workflow Demo</h2>
|
||||
<p>A triage workflow that routes questions to specialist agents — a Code Expert
|
||||
or a Creative Writer — using agent handoffs.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Workflow</span>
|
||||
<span class="tag">Handoffs</span>
|
||||
<span class="tag">Multi-Agent</span>
|
||||
<span class="tag">Triage</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<footer>
|
||||
All demos share the same <code>/responses</code> endpoint.
|
||||
The <code>model</code> field in the request selects which agent handles it.
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Tool Demo
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string ToolDemo = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Tool Demo — Foundry Responses Hosting</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
|
||||
main { width: 100%; max-width: 800px; }
|
||||
h1 { font-size: 1.2rem; margin-bottom: .3rem; color: #333; }
|
||||
.subtitle { font-size: .85rem; color: #666; margin-bottom: .8rem; }
|
||||
a.back { font-size: .85rem; color: #0066cc; text-decoration: none; display: inline-block; margin-bottom: 1rem; }
|
||||
#chat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; height: 56vh; overflow-y: auto; margin-bottom: 1rem; }
|
||||
.msg { margin-bottom: .75rem; line-height: 1.6; }
|
||||
.msg.user { color: #0066cc; }
|
||||
.msg.assistant { color: #333; }
|
||||
.msg .role { font-weight: 600; margin-right: .25rem; }
|
||||
.tool-call { background: #f0f4ff; border-left: 3px solid #4a90d9; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.tool-call .tool-icon { margin-right: .3rem; }
|
||||
form { display: flex; gap: .5rem; }
|
||||
input { flex: 1; padding: .6rem .8rem; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
|
||||
button { padding: .6rem 1.2rem; background: #0066cc; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
#status { font-size: .85rem; color: #888; margin-top: .5rem; }
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
|
||||
.suggestions button { padding: .3rem .7rem; font-size: .8rem; background: #e8f0fe; color: #1a73e8; border: 1px solid #c5d8f8; border-radius: 16px; cursor: pointer; }
|
||||
.suggestions button:hover { background: #d2e3fc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<a class="back" href="/">← Back to demos</a>
|
||||
<h1>🔧 Tool Demo</h1>
|
||||
<p class="subtitle">Agent with local tools (time, weather) + Microsoft Learn MCP (docs search)</p>
|
||||
<div class="suggestions">
|
||||
<button onclick="sendText('What time is it in Tokyo?')">🕐 Time in Tokyo</button>
|
||||
<button onclick="sendText('What is the weather in Seattle?')">🌤️ Weather in Seattle</button>
|
||||
<button onclick="sendText('How do I create an Azure Function using the CLI?')">📚 Azure Functions docs</button>
|
||||
<button onclick="sendText('What is Microsoft Agent Framework?')">📚 Agent Framework</button>
|
||||
</div>
|
||||
<div id="chat"></div>
|
||||
<form id="form">
|
||||
<input id="input" placeholder="Try: 'What time is it?' or 'Search docs for Azure AI Foundry'" autocomplete="off" autofocus />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
</main>
|
||||
<script src="/js/sse-validator.js"></script>
|
||||
<script>
|
||||
const AGENT = 'tool-agent';
|
||||
const chat = document.getElementById('chat');
|
||||
const form = document.getElementById('form');
|
||||
const input = document.getElementById('input');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function addMsg(role, html) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg ' + role; d.innerHTML = html;
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function addToolCall(name) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'tool-call';
|
||||
d.innerHTML = '<span class="tool-icon">🔧</span> Calling <b>' + escapeHtml(name) + '</b>…';
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function sendText(t) { input.value = t; form.dispatchEvent(new Event('submit')); }
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim(); if (!text) return;
|
||||
input.value = '';
|
||||
addMsg('user', '<span class="role">You:</span>' + escapeHtml(text));
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true; status.textContent = 'Streaming…';
|
||||
|
||||
let fullText = '', assistantDiv = null;
|
||||
const toolCalls = {};
|
||||
const validator = new SseValidator();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/responses', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: AGENT, stream: true, input: text })
|
||||
});
|
||||
if (!resp.ok) { status.textContent = 'Error ' + resp.status; btn.disabled = false; return; }
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '', curEvt = null;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n'); buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) { curEvt = line.slice(7).trim(); continue; }
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const d = line.slice(6).trim(); if (d === '[DONE]') continue;
|
||||
try {
|
||||
const evt = JSON.parse(d);
|
||||
validator.capture(curEvt || evt.type || 'unknown', d);
|
||||
curEvt = null;
|
||||
if (evt.type === 'response.output_item.added' && evt.item?.type === 'function_call') {
|
||||
const id = evt.item.id;
|
||||
toolCalls[id] = { name: evt.item.name || '?', args: '', el: addToolCall(evt.item.name || '?') };
|
||||
status.textContent = 'Calling tool: ' + (evt.item.name || '…');
|
||||
}
|
||||
if (evt.type === 'response.function_call_arguments.delta' && evt.item_id && toolCalls[evt.item_id])
|
||||
toolCalls[evt.item_id].args += (evt.delta || '');
|
||||
if (evt.type === 'response.function_call_arguments.done' && evt.item_id && toolCalls[evt.item_id]) {
|
||||
const tc = toolCalls[evt.item_id];
|
||||
let args = tc.args; try { args = JSON.stringify(JSON.parse(args), null, 0); } catch {}
|
||||
tc.el.innerHTML = '<span class="tool-icon">✅</span> Called <b>' + escapeHtml(tc.name) + '</b>(' + escapeHtml(args) + ')';
|
||||
}
|
||||
if (evt.type === 'response.output_text.delta') {
|
||||
if (!assistantDiv) assistantDiv = addMsg('assistant', '<span class="role">Agent:</span>');
|
||||
fullText += evt.delta;
|
||||
assistantDiv.innerHTML = '<span class="role">Agent:</span>' + escapeHtml(fullText);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
status.textContent = 'Streaming…';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!fullText && !assistantDiv) addMsg('assistant', '<span class="role">Agent:</span><em>(empty)</em>');
|
||||
status.textContent = '';
|
||||
} catch (err) { status.textContent = 'Error: ' + err.message; }
|
||||
if (validator.events.length > 0) {
|
||||
try { const vr = await validator.validate(); chat.appendChild(validator.renderElement(vr)); chat.scrollTop = chat.scrollHeight; } catch {}
|
||||
}
|
||||
btn.disabled = false; input.focus();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Workflow Demo
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string WorkflowDemo = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Workflow Demo — Foundry Responses Hosting</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 2rem; }
|
||||
main { width: 100%; max-width: 800px; }
|
||||
h1 { font-size: 1.2rem; margin-bottom: .3rem; color: #333; }
|
||||
.subtitle { font-size: .85rem; color: #666; margin-bottom: .8rem; }
|
||||
a.back { font-size: .85rem; color: #0066cc; text-decoration: none; display: inline-block; margin-bottom: 1rem; }
|
||||
#chat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; height: 56vh; overflow-y: auto; margin-bottom: 1rem; }
|
||||
.msg { margin-bottom: .75rem; line-height: 1.6; }
|
||||
.msg.user { color: #0066cc; }
|
||||
.msg.assistant { color: #333; }
|
||||
.msg .role { font-weight: 600; margin-right: .25rem; }
|
||||
.workflow-evt { background: #f0f9f0; border-left: 3px solid #4caf50; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; }
|
||||
.workflow-evt.failed { background: #fef0f0; border-left-color: #e53935; }
|
||||
.tool-call { background: #f0f4ff; border-left: 3px solid #4a90d9; padding: .4rem .6rem; margin: .4rem 0; border-radius: 4px; font-size: .85rem; color: #555; font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
form { display: flex; gap: .5rem; }
|
||||
input { flex: 1; padding: .6rem .8rem; border: 1px solid #ccc; border-radius: 6px; font-size: 1rem; }
|
||||
button { padding: .6rem 1.2rem; background: #0066cc; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
#status { font-size: .85rem; color: #888; margin-top: .5rem; }
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
|
||||
.suggestions button { padding: .3rem .7rem; font-size: .8rem; background: #e8f0fe; color: #1a73e8; border: 1px solid #c5d8f8; border-radius: 16px; cursor: pointer; }
|
||||
.suggestions button:hover { background: #d2e3fc; }
|
||||
.agent-diagram { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; font-size: .85rem; text-align: center; color: #555; }
|
||||
.agent-diagram .flow { font-size: 1.1rem; letter-spacing: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<a class="back" href="/">← Back to demos</a>
|
||||
<h1>🔀 Workflow Demo — Agent Handoffs</h1>
|
||||
<p class="subtitle">A triage agent routes your question to a specialist (Code Expert or Creative Writer)</p>
|
||||
<div class="agent-diagram">
|
||||
<div class="flow">👤 User → 🔀 <b>Triage</b> → 💻 <b>Code Expert</b> / ✍️ <b>Creative Writer</b></div>
|
||||
</div>
|
||||
<div class="suggestions">
|
||||
<button onclick="sendText('Write a Python function to reverse a linked list')">💻 Reverse linked list</button>
|
||||
<button onclick="sendText('Write me a haiku about cloud computing')">✍️ Cloud haiku</button>
|
||||
<button onclick="sendText('Explain the difference between async and threads in C#')">💻 Async vs threads</button>
|
||||
<button onclick="sendText('Write a short story about an AI that learns to paint')">✍️ AI painter story</button>
|
||||
</div>
|
||||
<div id="chat"></div>
|
||||
<form id="form">
|
||||
<input id="input" placeholder="Ask a coding question or request creative writing…" autocomplete="off" autofocus />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div id="status"></div>
|
||||
</main>
|
||||
<script src="/js/sse-validator.js"></script>
|
||||
<script>
|
||||
const AGENT = 'triage-workflow';
|
||||
const chat = document.getElementById('chat');
|
||||
const form = document.getElementById('form');
|
||||
const input = document.getElementById('input');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
function addMsg(role, html) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg ' + role; d.innerHTML = html;
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function addWorkflowEvent(icon, text, failed) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'workflow-evt' + (failed ? ' failed' : '');
|
||||
d.innerHTML = icon + ' ' + escapeHtml(text);
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
function addToolCall(name) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'tool-call';
|
||||
d.innerHTML = '🔀 Handoff: <b>' + escapeHtml(name) + '</b>';
|
||||
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
|
||||
}
|
||||
|
||||
function sendText(t) { input.value = t; form.dispatchEvent(new Event('submit')); }
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim(); if (!text) return;
|
||||
input.value = '';
|
||||
addMsg('user', '<span class="role">You:</span>' + escapeHtml(text));
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true; status.textContent = 'Running workflow…';
|
||||
|
||||
let fullText = '', assistantDiv = null;
|
||||
const toolCalls = {};
|
||||
const validator = new SseValidator();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/responses', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: AGENT, stream: true, input: text })
|
||||
});
|
||||
if (!resp.ok) { status.textContent = 'Error ' + resp.status; btn.disabled = false; return; }
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '', curEvt = null;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n'); buf = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) { curEvt = line.slice(7).trim(); continue; }
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const d = line.slice(6).trim(); if (d === '[DONE]') continue;
|
||||
try {
|
||||
const evt = JSON.parse(d);
|
||||
validator.capture(curEvt || evt.type || 'unknown', d);
|
||||
curEvt = null;
|
||||
|
||||
// Workflow events (executor invoked/completed/failed)
|
||||
if (evt.type === 'response.output_item.added' && evt.item?.type === 'workflow_action') {
|
||||
const s = evt.item.status;
|
||||
const id = evt.item.action_id || evt.item.actionId || '?';
|
||||
if (s === 'in_progress' || s === 'InProgress')
|
||||
addWorkflowEvent('▶️', 'Agent invoked: ' + id);
|
||||
else if (s === 'completed' || s === 'Completed')
|
||||
addWorkflowEvent('✅', 'Agent completed: ' + id);
|
||||
else if (s === 'failed' || s === 'Failed')
|
||||
addWorkflowEvent('❌', 'Agent failed: ' + id, true);
|
||||
}
|
||||
|
||||
// Handoff function calls
|
||||
if (evt.type === 'response.output_item.added' && evt.item?.type === 'function_call') {
|
||||
const id = evt.item.id;
|
||||
toolCalls[id] = { name: evt.item.name || '?', args: '', el: addToolCall(evt.item.name || '?') };
|
||||
status.textContent = 'Handoff: ' + (evt.item.name || '…');
|
||||
}
|
||||
if (evt.type === 'response.function_call_arguments.delta' && evt.item_id && toolCalls[evt.item_id])
|
||||
toolCalls[evt.item_id].args += (evt.delta || '');
|
||||
if (evt.type === 'response.function_call_arguments.done' && evt.item_id && toolCalls[evt.item_id]) {
|
||||
const tc = toolCalls[evt.item_id];
|
||||
let args = tc.args; try { args = JSON.stringify(JSON.parse(args), null, 0); } catch {}
|
||||
tc.el.innerHTML = '🔀 Handoff: <b>' + escapeHtml(tc.name) + '</b>(' + escapeHtml(args) + ')';
|
||||
}
|
||||
|
||||
// Text streaming from the specialist agent
|
||||
if (evt.type === 'response.output_text.delta') {
|
||||
if (!assistantDiv) assistantDiv = addMsg('assistant', '<span class="role">Agent:</span>');
|
||||
fullText += evt.delta;
|
||||
assistantDiv.innerHTML = '<span class="role">Agent:</span>' + escapeHtml(fullText);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
status.textContent = 'Streaming…';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!fullText && !assistantDiv) addMsg('assistant', '<span class="role">Agent:</span><em>(empty)</em>');
|
||||
status.textContent = '';
|
||||
} catch (err) { status.textContent = 'Error: ' + err.message; }
|
||||
if (validator.events.length > 0) {
|
||||
try { const vr = await validator.validate(); chat.appendChild(validator.renderElement(vr)); chat.scrollTop = chat.scrollHeight; } catch {}
|
||||
}
|
||||
btn.disabled = false; input.focus();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SSE Validator Script (shared by all demo pages)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal const string ValidationScript = """
|
||||
// SseValidator - inline SSE stream validation for Foundry Responses demos
|
||||
// Captures events during streaming and validates against the API behaviour contract.
|
||||
(function() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.sse-val { margin: .4rem 0 .6rem; padding: .3rem .5rem; font-size: .75rem; color: #aaa; border-top: 1px dashed #e8e8e8; }
|
||||
.val-ok { color: #7ab88a; }
|
||||
.val-err { color: #d47272; font-weight: 500; }
|
||||
.val-issues { margin: .2rem 0; }
|
||||
.val-issue { color: #c06060; font-size: .72rem; padding: .1rem 0; }
|
||||
.val-issue b { color: #b04040; }
|
||||
.val-at { color: #ccc; font-size: .68rem; }
|
||||
.val-log summary { cursor: pointer; color: #bbb; font-size: .72rem; }
|
||||
.val-log-items { max-height: 120px; overflow-y: auto; font-size: .7rem; background: #fafafa;
|
||||
padding: .3rem; border-radius: 3px; margin-top: .15rem;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.val-i { color: #ccc; display: inline-block; width: 1.8rem; text-align: right; margin-right: .3rem; }
|
||||
.val-t { color: #8ab4d0; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
||||
class SseValidator {
|
||||
constructor() { this.events = []; }
|
||||
reset() { this.events = []; }
|
||||
capture(eventType, data) { this.events.push({ eventType, data }); }
|
||||
|
||||
async validate() {
|
||||
const resp = await fetch('/api/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ events: this.events })
|
||||
});
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
renderElement(result) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sse-val';
|
||||
const n = result.eventCount;
|
||||
const ok = result.isValid;
|
||||
const vs = result.violations || [];
|
||||
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
let h = ok
|
||||
? `<span class="val-ok">${n} events — all rules passed ✅</span>`
|
||||
: `<span class="val-err">${n} events — ${vs.length} violation(s)</span>`;
|
||||
|
||||
if (vs.length) {
|
||||
h += '<div class="val-issues">';
|
||||
vs.forEach(v => {
|
||||
h += `<div class="val-issue"><b>[${esc(v.ruleId)}]</b> ${esc(v.message)} <span class="val-at">#${v.eventIndex}</span></div>`;
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
h += `<details class="val-log"><summary>Event log (${this.events.length})</summary><div class="val-log-items">`;
|
||||
this.events.forEach((e, i) => {
|
||||
h += `<div><span class="val-i">${i}</span> <span class="val-t">${esc(e.eventType)}</span></div>`;
|
||||
});
|
||||
h += '</div></details>';
|
||||
|
||||
el.innerHTML = h;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents
|
||||
// using the Azure AI Responses Server SDK.
|
||||
//
|
||||
// Demos:
|
||||
// / - Homepage listing all demos
|
||||
// /tool-demo - Agent with local tools + remote MCP tools
|
||||
// /workflow-demo - Triage workflow routing to specialist agents
|
||||
//
|
||||
// Prerequisites:
|
||||
// - Azure OpenAI resource with a deployed model
|
||||
//
|
||||
// Environment variables:
|
||||
// - AZURE_OPENAI_ENDPOINT - your Azure OpenAI endpoint
|
||||
// - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o")
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Create the shared Azure OpenAI chat client
|
||||
// ---------------------------------------------------------------------------
|
||||
var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."));
|
||||
var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o";
|
||||
|
||||
var azureClient = new AzureOpenAIClient(endpoint, new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential()));
|
||||
IChatClient chatClient = azureClient.GetResponsesClient().AsIChatClient(deployment);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP
|
||||
// ---------------------------------------------------------------------------
|
||||
Console.WriteLine("Connecting to Microsoft Learn MCP server...");
|
||||
McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
Name = "Microsoft Learn MCP",
|
||||
}));
|
||||
var mcpTools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
|
||||
|
||||
builder.AddAIAgent(
|
||||
name: "tool-agent",
|
||||
instructions: """
|
||||
You are a helpful assistant hosted as a Foundry Hosted Agent.
|
||||
You have access to several tools - use them proactively:
|
||||
- GetCurrentTime: Returns the current date/time in any timezone.
|
||||
- GetWeather: Returns weather conditions for any location.
|
||||
- Microsoft Learn MCP tools: Search and fetch Microsoft documentation.
|
||||
When a user asks a technical question about Microsoft products, use the
|
||||
documentation search tools to give accurate, up-to-date answers.
|
||||
""",
|
||||
chatClient: chatClient)
|
||||
.WithAITool(AIFunctionFactory.Create(GetCurrentTime))
|
||||
.WithAITool(AIFunctionFactory.Create(GetWeather))
|
||||
.WithAITools(mcpTools.Cast<AITool>().ToArray());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. DEMO 2: Triage Workflow — routes to specialist agents
|
||||
// ---------------------------------------------------------------------------
|
||||
ChatClientAgent triageAgent = new(
|
||||
chatClient,
|
||||
instructions: """
|
||||
You are a triage agent that determines which specialist to hand off to.
|
||||
Based on the user's question, ALWAYS hand off to one of the available agents.
|
||||
Do NOT answer the question yourself - just route it.
|
||||
""",
|
||||
name: "triage_agent",
|
||||
description: "Routes messages to the appropriate specialist agent");
|
||||
|
||||
ChatClientAgent codeExpert = new(
|
||||
chatClient,
|
||||
instructions: """
|
||||
You are a coding and technology expert. You help with programming questions,
|
||||
explain technical concepts, debug code, and suggest best practices.
|
||||
Provide clear, well-structured answers with code examples when appropriate.
|
||||
""",
|
||||
name: "code_expert",
|
||||
description: "Specialist agent for programming and technology questions");
|
||||
|
||||
ChatClientAgent creativeWriter = new(
|
||||
chatClient,
|
||||
instructions: """
|
||||
You are a creative writing specialist. You help write stories, poems,
|
||||
marketing copy, emails, and other creative content. You have a flair
|
||||
for engaging language and vivid descriptions.
|
||||
""",
|
||||
name: "creative_writer",
|
||||
description: "Specialist agent for creative writing and content tasks");
|
||||
|
||||
Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent)
|
||||
.WithHandoffs(triageAgent, [codeExpert, creativeWriter])
|
||||
.WithHandoffs([codeExpert, creativeWriter], triageAgent)
|
||||
.Build();
|
||||
|
||||
builder.AddAIAgent("triage-workflow", (_, key) =>
|
||||
triageWorkflow.AsAIAgent(name: key));
|
||||
|
||||
// Register triage-workflow as the non-keyed default so azd invoke (no model) works
|
||||
builder.Services.AddSingleton(sp =>
|
||||
sp.GetRequiredKeyedService<AIAgent>("triage-workflow"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Wire up the agent-framework handler and Responses Server SDK
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddFoundryResponses();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Dispose the MCP client on shutdown
|
||||
app.Lifetime.ApplicationStopping.Register(() =>
|
||||
mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
app.MapGet("/ready", () => Results.Ok("ready"));
|
||||
app.MapFoundryResponses();
|
||||
|
||||
app.MapGet("/", () => Results.Content(Pages.Home, "text/html"));
|
||||
app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html"));
|
||||
app.MapGet("/workflow-demo", () => Results.Content(Pages.WorkflowDemo, "text/html"));
|
||||
app.MapGet("/js/sse-validator.js", () => Results.Content(Pages.ValidationScript, "application/javascript"));
|
||||
|
||||
// Validation endpoint: accepts captured SSE lines and validates them
|
||||
app.MapPost("/api/validate", (HostedWorkflowHandoff.CapturedSseStream captured) =>
|
||||
{
|
||||
var validator = new HostedWorkflowHandoff.ResponseStreamValidator();
|
||||
foreach (var evt in captured.Events)
|
||||
{
|
||||
validator.ProcessEvent(evt.EventType, evt.Data);
|
||||
}
|
||||
|
||||
validator.Complete();
|
||||
return Results.Json(validator.GetResult());
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local tool definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only credential: reads a pre-fetched bearer token from AZURE_BEARER_TOKEN.
|
||||
// When the value is missing or set to "DefaultAzureCredential", this credential
|
||||
// throws CredentialUnavailableException so the ChainedTokenCredential falls
|
||||
// through to DefaultAzureCredential.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Description("Gets the current date and time in the specified timezone.")]
|
||||
static string GetCurrentTime(
|
||||
[Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")]
|
||||
string timezone = "UTC")
|
||||
{
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone);
|
||||
return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("F");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.UtcNow.ToString("F") + " (UTC - unknown timezone: " + timezone + ")";
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Gets the current weather for a location. Returns temperature, conditions, and humidity.")]
|
||||
static string GetWeather(
|
||||
[Description("The city or location (e.g. 'Seattle', 'London, UK').")]
|
||||
string location)
|
||||
{
|
||||
// Simulated weather - deterministic per location for demo consistency
|
||||
var rng = new Random(location.ToUpperInvariant().GetHashCode());
|
||||
var temp = rng.Next(-5, 35);
|
||||
string[] conditions = ["sunny", "partly cloudy", "overcast", "rainy", "snowy", "windy", "foggy"];
|
||||
var condition = conditions[rng.Next(conditions.Length)];
|
||||
return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h.";
|
||||
}
|
||||
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
# Hosted-Workflow-Handoff
|
||||
|
||||
A hosted agent server demonstrating two patterns in a single app:
|
||||
|
||||
- **`tool-agent`** — an agent with local tools (time, weather) plus remote Microsoft Learn MCP tools
|
||||
- **`triage-workflow`** — a handoff workflow that routes conversations to specialist agents (code expert or creative writer) using `AgentWorkflowBuilder`
|
||||
|
||||
Both agents are served over the Responses protocol. The server also exposes interactive web demos at `/tool-demo` and `/workflow-demo`.
|
||||
|
||||
> Unlike the other samples in this folder, this one connects to an **Azure OpenAI** resource directly (not an Azure AI Foundry project endpoint).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure OpenAI resource with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
AZURE_OPENAI_ENDPOINT=https://<your-account>.openai.azure.com/
|
||||
AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
`AZURE_BEARER_TOKEN=DefaultAzureCredential` is a sentinel value that tells the app to skip the bearer token and fall through to `DefaultAzureCredential` (requires `az login`). Set it to a real token only when running in Docker.
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:8088`. Open `http://localhost:8088` to see the demo index page.
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI (invokes `triage-workflow` — the primary/default agent):
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Write me a short poem about coding"
|
||||
```
|
||||
|
||||
To target a specific agent by name, use curl:
|
||||
|
||||
```bash
|
||||
# Invoke triage-workflow explicitly
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Write me a haiku about autumn", "model": "triage-workflow"}'
|
||||
```
|
||||
|
||||
```bash
|
||||
# Invoke tool-agent (local tools + MCP)
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What time is it in Tokyo?", "model": "tool-agent"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
### 1. Publish for the container runtime
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-workflow-handoff .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
```bash
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-workflow-handoff
|
||||
```
|
||||
|
||||
### 4. Test it
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Explain async/await in C#"
|
||||
```
|
||||
|
||||
## How the triage workflow works
|
||||
|
||||
```
|
||||
User message
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Triage Agent │ ──routes──▶ ┌─────────────┐
|
||||
│ (router) │ │ Code Expert │
|
||||
└──────────────┘ └─────────────┘
|
||||
▲ │
|
||||
│◀──────────────────────────────┘
|
||||
│
|
||||
└──routes──▶ ┌─────────────────┐
|
||||
│ Creative Writer │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative.
|
||||
-601
@@ -1,601 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace HostedWorkflowHandoff;
|
||||
|
||||
/// <summary>Captured SSE event for validation.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")]
|
||||
internal sealed record CapturedSseEvent(
|
||||
[property: JsonPropertyName("eventType")] string EventType,
|
||||
[property: JsonPropertyName("data")] string Data);
|
||||
|
||||
/// <summary>Captured SSE stream sent from the client for server-side validation.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")]
|
||||
internal sealed record CapturedSseStream(
|
||||
[property: JsonPropertyName("events")] List<CapturedSseEvent> Events);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an SSE event stream from the Azure AI Responses Server SDK against
|
||||
/// the API behaviour contract. Feed events sequentially via <see cref="ProcessEvent"/>
|
||||
/// and call <see cref="Complete"/> when the stream ends.
|
||||
/// </summary>
|
||||
internal sealed class ResponseStreamValidator
|
||||
{
|
||||
private readonly List<ValidationViolation> _violations = [];
|
||||
private int _eventCount;
|
||||
private int _expectedSequenceNumber;
|
||||
private StreamState _state = StreamState.Initial;
|
||||
private string? _responseId;
|
||||
private readonly HashSet<int> _addedItemIndices = [];
|
||||
private readonly HashSet<int> _doneItemIndices = [];
|
||||
private readonly HashSet<string> _addedContentParts = []; // "outputIdx:partIdx"
|
||||
private readonly HashSet<string> _doneContentParts = [];
|
||||
private readonly Dictionary<string, string> _textAccumulators = []; // "outputIdx:contentIdx" → accumulated text
|
||||
private bool _hasTerminal;
|
||||
|
||||
/// <summary>All violations found so far.</summary>
|
||||
internal IReadOnlyList<ValidationViolation> Violations => this._violations;
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single SSE event line pair (event type + JSON data).
|
||||
/// </summary>
|
||||
/// <param name="eventType">The SSE event type (e.g. "response.created").</param>
|
||||
/// <param name="jsonData">The raw JSON data payload.</param>
|
||||
internal void ProcessEvent(string eventType, string jsonData)
|
||||
{
|
||||
JsonElement data;
|
||||
try
|
||||
{
|
||||
data = JsonDocument.Parse(jsonData).RootElement;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
this.Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
this._eventCount++;
|
||||
|
||||
// ── Sequence number validation ──────────────────────────────────
|
||||
if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
int seq = seqProp.GetInt32();
|
||||
if (seq != this._expectedSequenceNumber)
|
||||
{
|
||||
this.Fail("SEQ-01", $"Expected sequence_number {this._expectedSequenceNumber}, got {seq}");
|
||||
}
|
||||
|
||||
this._expectedSequenceNumber = seq + 1;
|
||||
}
|
||||
else if (this._state != StreamState.Initial || eventType != "error")
|
||||
{
|
||||
// Pre-creation error events may not have sequence_number
|
||||
this.Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'");
|
||||
}
|
||||
|
||||
// ── Post-terminal guard ─────────────────────────────────────────
|
||||
if (this._hasTerminal)
|
||||
{
|
||||
this.Fail("TERM-01", $"Event '{eventType}' received after terminal event");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Dispatch by event type ──────────────────────────────────────
|
||||
switch (eventType)
|
||||
{
|
||||
case "response.created":
|
||||
this.ValidateResponseCreated(data);
|
||||
break;
|
||||
|
||||
case "response.queued":
|
||||
this.ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued);
|
||||
this.ValidateResponseEnvelope(data, eventType);
|
||||
break;
|
||||
|
||||
case "response.in_progress":
|
||||
if (this._state is StreamState.Created or StreamState.Queued)
|
||||
{
|
||||
this._state = StreamState.InProgress;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Fail("ORDER-02", $"'response.in_progress' received in state {this._state} (expected Created or Queued)");
|
||||
}
|
||||
|
||||
this.ValidateResponseEnvelope(data, eventType);
|
||||
break;
|
||||
|
||||
case "response.output_item.added":
|
||||
case "output_item.added":
|
||||
this.ValidateInProgress(eventType);
|
||||
this.ValidateOutputItemAdded(data);
|
||||
break;
|
||||
|
||||
case "response.output_item.done":
|
||||
case "output_item.done":
|
||||
this.ValidateInProgress(eventType);
|
||||
this.ValidateOutputItemDone(data);
|
||||
break;
|
||||
|
||||
case "response.content_part.added":
|
||||
case "content_part.added":
|
||||
this.ValidateInProgress(eventType);
|
||||
this.ValidateContentPartAdded(data);
|
||||
break;
|
||||
|
||||
case "response.content_part.done":
|
||||
case "content_part.done":
|
||||
this.ValidateInProgress(eventType);
|
||||
this.ValidateContentPartDone(data);
|
||||
break;
|
||||
|
||||
case "response.output_text.delta":
|
||||
case "output_text.delta":
|
||||
this.ValidateInProgress(eventType);
|
||||
this.ValidateTextDelta(data);
|
||||
break;
|
||||
|
||||
case "response.output_text.done":
|
||||
case "output_text.done":
|
||||
this.ValidateInProgress(eventType);
|
||||
this.ValidateTextDone(data);
|
||||
break;
|
||||
|
||||
case "response.function_call_arguments.delta":
|
||||
case "function_call_arguments.delta":
|
||||
this.ValidateInProgress(eventType);
|
||||
break;
|
||||
|
||||
case "response.function_call_arguments.done":
|
||||
case "function_call_arguments.done":
|
||||
this.ValidateInProgress(eventType);
|
||||
break;
|
||||
|
||||
case "response.completed":
|
||||
this.ValidateTerminal(data, "completed");
|
||||
break;
|
||||
|
||||
case "response.failed":
|
||||
this.ValidateTerminal(data, "failed");
|
||||
break;
|
||||
|
||||
case "response.incomplete":
|
||||
this.ValidateTerminal(data, "incomplete");
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// Pre-creation error — standalone, no response.created precedes it
|
||||
if (this._state != StreamState.Initial)
|
||||
{
|
||||
this.Fail("ERR-01", "'error' event received after response.created — should use response.failed instead");
|
||||
}
|
||||
|
||||
this._hasTerminal = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown events are not violations — the spec may evolve
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call after the stream ends. Checks that a terminal event was received.
|
||||
/// </summary>
|
||||
internal void Complete()
|
||||
{
|
||||
if (!this._hasTerminal && this._state != StreamState.Initial)
|
||||
{
|
||||
this.Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)");
|
||||
}
|
||||
|
||||
if (this._state == StreamState.Initial && this._eventCount == 0)
|
||||
{
|
||||
this.Fail("EMPTY-01", "No events received in the stream");
|
||||
}
|
||||
|
||||
// Check for output items that were added but never completed
|
||||
foreach (int idx in this._addedItemIndices)
|
||||
{
|
||||
if (!this._doneItemIndices.Contains(idx))
|
||||
{
|
||||
this.Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for content parts that were added but never completed
|
||||
foreach (string key in this._addedContentParts)
|
||||
{
|
||||
if (!this._doneContentParts.Contains(key))
|
||||
{
|
||||
this.Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a summary of all validation results.
|
||||
/// </summary>
|
||||
internal ValidationResult GetResult()
|
||||
{
|
||||
return new ValidationResult(
|
||||
EventCount: this._eventCount,
|
||||
IsValid: this._violations.Count == 0,
|
||||
Violations: [.. this._violations]);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Event-specific validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private void ValidateResponseCreated(JsonElement data)
|
||||
{
|
||||
if (this._state != StreamState.Initial)
|
||||
{
|
||||
this.Fail("ORDER-01", $"'response.created' received in state {this._state} (expected Initial — must be first event)");
|
||||
return;
|
||||
}
|
||||
|
||||
this._state = StreamState.Created;
|
||||
|
||||
// Must have a response envelope
|
||||
if (!data.TryGetProperty("response", out var resp))
|
||||
{
|
||||
this.Fail("FIELD-01", "'response.created' missing 'response' object");
|
||||
return;
|
||||
}
|
||||
|
||||
// Required response fields
|
||||
this.ValidateRequiredResponseFields(resp, "response.created");
|
||||
|
||||
// Capture response ID for cross-event checks
|
||||
if (resp.TryGetProperty("id", out var idProp))
|
||||
{
|
||||
this._responseId = idProp.GetString();
|
||||
}
|
||||
|
||||
// Status must be non-terminal
|
||||
if (resp.TryGetProperty("status", out var statusProp))
|
||||
{
|
||||
string? status = statusProp.GetString();
|
||||
if (status is "completed" or "failed" or "incomplete" or "cancelled")
|
||||
{
|
||||
this.Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTerminal(JsonElement data, string expectedKind)
|
||||
{
|
||||
if (this._state is StreamState.Initial or StreamState.Created)
|
||||
{
|
||||
this.Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'");
|
||||
}
|
||||
|
||||
this._hasTerminal = true;
|
||||
this._state = StreamState.Terminal;
|
||||
|
||||
if (!data.TryGetProperty("response", out var resp))
|
||||
{
|
||||
this.Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ValidateRequiredResponseFields(resp, $"response.{expectedKind}");
|
||||
|
||||
if (resp.TryGetProperty("status", out var statusProp))
|
||||
{
|
||||
string? status = statusProp.GetString();
|
||||
|
||||
// completed_at validation (B6)
|
||||
bool hasCompletedAt = resp.TryGetProperty("completed_at", out var catProp)
|
||||
&& catProp.ValueKind != JsonValueKind.Null;
|
||||
|
||||
if (status == "completed" && !hasCompletedAt)
|
||||
{
|
||||
this.Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'");
|
||||
}
|
||||
|
||||
if (status != "completed" && hasCompletedAt)
|
||||
{
|
||||
this.Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'");
|
||||
}
|
||||
|
||||
// error field validation
|
||||
bool hasError = resp.TryGetProperty("error", out var errProp)
|
||||
&& errProp.ValueKind != JsonValueKind.Null;
|
||||
|
||||
if (status == "failed" && !hasError)
|
||||
{
|
||||
this.Fail("FIELD-04", "'error' must be non-null when status is 'failed'");
|
||||
}
|
||||
|
||||
if (status is "completed" or "incomplete" && hasError)
|
||||
{
|
||||
this.Fail("FIELD-05", $"'error' must be null when status is '{status}'");
|
||||
}
|
||||
|
||||
// error structure validation
|
||||
if (hasError)
|
||||
{
|
||||
this.ValidateErrorObject(errProp, $"response.{expectedKind}");
|
||||
}
|
||||
|
||||
// cancelled output must be empty (B11)
|
||||
if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp)
|
||||
&& outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0)
|
||||
{
|
||||
this.Fail("CANCEL-01", "Cancelled response must have empty output array (B11)");
|
||||
}
|
||||
|
||||
// response ID consistency
|
||||
if (this._responseId is not null && resp.TryGetProperty("id", out var idProp)
|
||||
&& idProp.GetString() != this._responseId)
|
||||
{
|
||||
this.Fail("ID-01", $"Response ID changed: was '{this._responseId}', now '{idProp.GetString()}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Usage validation (optional, but if present must be structured correctly)
|
||||
if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
this.ValidateUsage(usageProp, $"response.{expectedKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateOutputItemAdded(JsonElement data)
|
||||
{
|
||||
if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
int index = idxProp.GetInt32();
|
||||
if (!this._addedItemIndices.Add(index))
|
||||
{
|
||||
this.Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Fail("FIELD-06", "output_item.added missing 'output_index' field");
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("item", out _))
|
||||
{
|
||||
this.Fail("FIELD-07", "output_item.added missing 'item' object");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateOutputItemDone(JsonElement data)
|
||||
{
|
||||
if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
int index = idxProp.GetInt32();
|
||||
if (!this._addedItemIndices.Contains(index))
|
||||
{
|
||||
this.Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added");
|
||||
}
|
||||
|
||||
this._doneItemIndices.Add(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Fail("FIELD-06", "output_item.done missing 'output_index' field");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateContentPartAdded(JsonElement data)
|
||||
{
|
||||
string key = GetContentPartKey(data);
|
||||
if (!this._addedContentParts.Add(key))
|
||||
{
|
||||
this.Fail("CONTENT-01", $"Duplicate content_part.added for {key}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateContentPartDone(JsonElement data)
|
||||
{
|
||||
string key = GetContentPartKey(data);
|
||||
if (!this._addedContentParts.Contains(key))
|
||||
{
|
||||
this.Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added");
|
||||
}
|
||||
|
||||
this._doneContentParts.Add(key);
|
||||
}
|
||||
|
||||
private void ValidateTextDelta(JsonElement data)
|
||||
{
|
||||
string key = GetTextKey(data);
|
||||
string delta = data.TryGetProperty("delta", out var deltaProp)
|
||||
? deltaProp.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
if (!this._textAccumulators.TryGetValue(key, out string? existing))
|
||||
{
|
||||
this._textAccumulators[key] = delta;
|
||||
}
|
||||
else
|
||||
{
|
||||
this._textAccumulators[key] = existing + delta;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTextDone(JsonElement data)
|
||||
{
|
||||
string key = GetTextKey(data);
|
||||
string? finalText = data.TryGetProperty("text", out var textProp)
|
||||
? textProp.GetString()
|
||||
: null;
|
||||
|
||||
if (finalText is null)
|
||||
{
|
||||
this.Fail("TEXT-01", $"output_text.done for {key} missing 'text' field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText)
|
||||
{
|
||||
this.Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Shared field validators
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private void ValidateRequiredResponseFields(JsonElement resp, string context)
|
||||
{
|
||||
if (!HasNonNullString(resp, "id"))
|
||||
{
|
||||
this.Fail("FIELD-01", $"{context}: response missing 'id'");
|
||||
}
|
||||
|
||||
if (resp.TryGetProperty("object", out var objProp))
|
||||
{
|
||||
if (objProp.GetString() != "response")
|
||||
{
|
||||
this.Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Fail("FIELD-08", $"{context}: response missing 'object' field");
|
||||
}
|
||||
|
||||
if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
this.Fail("FIELD-09", $"{context}: response missing 'created_at'");
|
||||
}
|
||||
|
||||
if (!resp.TryGetProperty("status", out _))
|
||||
{
|
||||
this.Fail("FIELD-10", $"{context}: response missing 'status'");
|
||||
}
|
||||
|
||||
if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
this.Fail("FIELD-11", $"{context}: response missing 'output' array");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateErrorObject(JsonElement error, string context)
|
||||
{
|
||||
if (!HasNonNullString(error, "code"))
|
||||
{
|
||||
this.Fail("ERR-02", $"{context}: error object missing 'code' field");
|
||||
}
|
||||
|
||||
if (!HasNonNullString(error, "message"))
|
||||
{
|
||||
this.Fail("ERR-03", $"{context}: error object missing 'message' field");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateUsage(JsonElement usage, string context)
|
||||
{
|
||||
if (!usage.TryGetProperty("input_tokens", out _))
|
||||
{
|
||||
this.Fail("USAGE-01", $"{context}: usage missing 'input_tokens'");
|
||||
}
|
||||
|
||||
if (!usage.TryGetProperty("output_tokens", out _))
|
||||
{
|
||||
this.Fail("USAGE-02", $"{context}: usage missing 'output_tokens'");
|
||||
}
|
||||
|
||||
if (!usage.TryGetProperty("total_tokens", out _))
|
||||
{
|
||||
this.Fail("USAGE-03", $"{context}: usage missing 'total_tokens'");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateResponseEnvelope(JsonElement data, string eventType)
|
||||
{
|
||||
if (!data.TryGetProperty("response", out var resp))
|
||||
{
|
||||
this.Fail("FIELD-01", $"'{eventType}' missing 'response' object");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ValidateRequiredResponseFields(resp, eventType);
|
||||
|
||||
// Response ID consistency
|
||||
if (this._responseId is not null && resp.TryGetProperty("id", out var idProp)
|
||||
&& idProp.GetString() != this._responseId)
|
||||
{
|
||||
this.Fail("ID-01", $"Response ID changed: was '{this._responseId}', now '{idProp.GetString()}'");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private void ValidateInProgress(string eventType)
|
||||
{
|
||||
if (this._state != StreamState.InProgress)
|
||||
{
|
||||
this.Fail("ORDER-04", $"'{eventType}' received in state {this._state} (expected InProgress)");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateStateTransition(string eventType, StreamState expected, StreamState next)
|
||||
{
|
||||
if (this._state != expected)
|
||||
{
|
||||
this.Fail("ORDER-05", $"'{eventType}' received in state {this._state} (expected {expected})");
|
||||
}
|
||||
else
|
||||
{
|
||||
this._state = next;
|
||||
}
|
||||
}
|
||||
|
||||
private void Fail(string ruleId, string message)
|
||||
{
|
||||
this._violations.Add(new ValidationViolation(ruleId, message, this._eventCount));
|
||||
}
|
||||
|
||||
private static bool HasNonNullString(JsonElement obj, string property)
|
||||
{
|
||||
return obj.TryGetProperty(property, out var prop)
|
||||
&& prop.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrEmpty(prop.GetString());
|
||||
}
|
||||
|
||||
private static string GetContentPartKey(JsonElement data)
|
||||
{
|
||||
int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1;
|
||||
int partIdx = data.TryGetProperty("content_index", out var pi) ? pi.GetInt32() : -1;
|
||||
return $"{outputIdx}:{partIdx}";
|
||||
}
|
||||
|
||||
private static string GetTextKey(JsonElement data)
|
||||
{
|
||||
int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1;
|
||||
int contentIdx = data.TryGetProperty("content_index", out var ci) ? ci.GetInt32() : -1;
|
||||
return $"{outputIdx}:{contentIdx}";
|
||||
}
|
||||
|
||||
private enum StreamState
|
||||
{
|
||||
Initial,
|
||||
Created,
|
||||
Queued,
|
||||
InProgress,
|
||||
Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A single validation violation.</summary>
|
||||
/// <param name="RuleId">The rule identifier (e.g. SEQ-01, FIELD-02).</param>
|
||||
/// <param name="Message">Human-readable description of the violation.</param>
|
||||
/// <param name="EventIndex">1-based index of the event that triggered this violation.</param>
|
||||
internal sealed record ValidationViolation(string RuleId, string Message, int EventIndex);
|
||||
|
||||
/// <summary>Overall validation result.</summary>
|
||||
/// <param name="EventCount">Total number of events processed.</param>
|
||||
/// <param name="IsValid">True if no violations were found.</param>
|
||||
/// <param name="Violations">List of all violations.</param>
|
||||
internal sealed record ValidationResult(int EventCount, bool IsValid, IReadOnlyList<ValidationViolation> Violations);
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: triage-workflow
|
||||
displayName: "Triage Handoff Workflow Agent"
|
||||
|
||||
description: >
|
||||
A hosted agent demonstrating two patterns in a single server: a tool-equipped agent
|
||||
with local tools and remote MCP tools, and a triage workflow that routes conversations
|
||||
to specialist agents (code expert or creative writer) via handoff orchestration.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Workflows
|
||||
- Handoff
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: triage-workflow
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user