mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5419b9f38 | ||
|
|
03e47b5232 | ||
|
|
46ab47b9e1 | ||
|
|
094f9903b3 | ||
|
|
8b71f9459a | ||
|
|
866a325b48 | ||
|
|
40e90c96c3 | ||
|
|
1e1eda65ce | ||
|
|
3a463b8bf6 | ||
|
|
74a5ea8dca | ||
|
|
df6041bcc1 | ||
|
|
e6c29f8fa4 | ||
|
|
2c35be877d | ||
|
|
0a27c74245 | ||
|
|
7c4837744b | ||
|
|
870f10829e | ||
|
|
5ba7f8aa6f | ||
|
|
35a0b51523 | ||
|
|
d28c841c50 | ||
|
|
7d305d461c | ||
|
|
8f4efe5fb9 | ||
|
|
362c4c5f84 | ||
|
|
27a6f47a3b | ||
|
|
198a3a1ab1 | ||
|
|
88347f6494 | ||
|
|
9b22ecd119 | ||
|
|
2eb0705ee0 | ||
|
|
dad3652f46 | ||
|
|
56fb634f0e | ||
|
|
56c3f8d825 | ||
|
|
0b69d7fd15 | ||
|
|
7b70f80036 | ||
|
|
da32e8cf80 | ||
|
|
62e02da698 | ||
|
|
63c0a51797 | ||
|
|
b00465d7be | ||
|
|
4adfd244ac | ||
|
|
932ceddf95 | ||
|
|
0989e68d1c | ||
|
|
b084d0461d | ||
|
|
5fe8941ff9 | ||
|
|
0dbcc9fe9d | ||
|
|
4d3e4f865f | ||
|
|
69adf6d97e | ||
|
|
6851a9cdc8 | ||
|
|
dfca81ff21 | ||
|
|
fbbc2ebe86 | ||
|
|
c9e6033048 | ||
|
|
9ca55dcc0c | ||
|
|
58ff4ad3a9 | ||
|
|
66e02c10e3 | ||
|
|
acec9caa2f | ||
|
|
5d4873888f | ||
|
|
e2f161c8a0 |
@@ -0,0 +1,61 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,178 @@
|
||||
// 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')));
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,10 @@ jobs:
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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"
|
||||
@@ -336,6 +336,53 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Integration Tests - Foundry Hosting
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
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
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- 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: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-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-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
name: Python Integration Tests - Cosmos
|
||||
@@ -402,6 +449,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -465,6 +513,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -80,6 +81,8 @@ jobs:
|
||||
- 'python/packages/foundry/**'
|
||||
- 'python/samples/**/providers/foundry/**'
|
||||
- 'python/samples/02-agents/embeddings/foundry_embeddings.py'
|
||||
foundry_hosting:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
# run only if 'python' files were changed
|
||||
@@ -488,6 +491,67 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry Hosting integration tests
|
||||
python-tests-foundry-hosting:
|
||||
name: Python Tests - Foundry Hosting Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.foundryHostingChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Azure CLI Login
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest (Foundry Hosting integration)
|
||||
timeout-minutes: 15
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/foundry_hosting/tests
|
||||
-m integration
|
||||
-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()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Foundry Hosting integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry-hosting
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
@@ -569,6 +633,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -629,6 +694,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
|
||||
@@ -242,3 +242,7 @@ python/dotnet-ref
|
||||
# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1)
|
||||
dotnet/filtered-*.slnx
|
||||
**/*.lscache
|
||||
|
||||
# Local tool state
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<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.22" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
|
||||
<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" />
|
||||
@@ -42,29 +42,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.4" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<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.4" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
|
||||
<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.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" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
@@ -104,8 +104,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="0.3.4-preview" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
|
||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Inference SDKs -->
|
||||
@@ -188,4 +188,4 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -4,9 +4,6 @@
|
||||
<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" />
|
||||
@@ -67,6 +64,7 @@
|
||||
<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" />
|
||||
@@ -162,6 +160,7 @@
|
||||
<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" />
|
||||
@@ -344,11 +343,13 @@
|
||||
<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/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/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/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
@@ -531,6 +532,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.2.0</VersionPrefix>
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260421</DateSuffix>
|
||||
<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="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.2.0</GitTag>
|
||||
<GitTag>1.3.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+1
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
+5
-1
@@ -18,8 +18,12 @@ 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);
|
||||
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);
|
||||
|
||||
// Poll until the response is complete.
|
||||
while (response.ContinuationToken is { } token)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
```
|
||||
@@ -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](../../02-agents/Agents/README.md) samples.
|
||||
see the [Getting Started With Agents](../Agents/README.md) samples.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -15,6 +15,8 @@ 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
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
// This is provided for demonstration purposes only.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runner uses the script's absolute path, converts the arguments
|
||||
/// to CLI flags, and returns captured output. It is intended for
|
||||
/// demonstration purposes only.
|
||||
/// This runner uses the script's absolute path and converts the arguments
|
||||
/// to CLI arguments. When the LLM sends a JSON array, each element is used
|
||||
/// as a positional argument. It is intended for demonstration purposes only.
|
||||
/// </remarks>
|
||||
internal static class SubprocessScriptRunner
|
||||
{
|
||||
@@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner
|
||||
public static async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(script.FullPath))
|
||||
@@ -61,24 +62,27 @@ internal static class SubprocessScriptRunner
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is not null)
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
{
|
||||
foreach (var (key, value) in arguments)
|
||||
// Positional CLI arguments
|
||||
foreach (var element in json.EnumerateArray())
|
||||
{
|
||||
if (value is bool boolValue)
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
if (boolValue)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
}
|
||||
}
|
||||
else if (value is not null)
|
||||
{
|
||||
startInfo.ArgumentList.Add(NormalizeKey(key));
|
||||
startInfo.ArgumentList.Add(value.ToString()!);
|
||||
throw new InvalidOperationException(
|
||||
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
|
||||
"All array elements must be JSON strings.");
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(element.GetString()!);
|
||||
}
|
||||
}
|
||||
else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
|
||||
"File-based skill scripts expect positional arguments as a JSON array of strings.");
|
||||
}
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
@@ -128,10 +132,4 @@ internal static class SubprocessScriptRunner
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parameter key to a consistent --flag format.
|
||||
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
|
||||
/// </summary>
|
||||
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,281 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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,6 +46,7 @@ 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
@@ -0,0 +1,17 @@
|
||||
<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
@@ -0,0 +1,148 @@
|
||||
// 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
@@ -0,0 +1,46 @@
|
||||
# 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,3 +19,4 @@ 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 |
|
||||
|
||||
+47
@@ -65,6 +65,53 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45
|
||||
>
|
||||
> If not provided, a unique run ID is auto-generated.
|
||||
|
||||
### Wait for the Workflow Result
|
||||
|
||||
By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "x-ms-wait-for-response: true" \
|
||||
-d "12345"
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/workflows/CancelOrder/run `
|
||||
-ContentType text/plain `
|
||||
-Headers @{ "x-ms-wait-for-response" = "true" } `
|
||||
-Body "12345"
|
||||
```
|
||||
|
||||
The response will contain the workflow result as plain text (200 OK):
|
||||
|
||||
```text
|
||||
Cancellation email sent for order 12345 to jerry@example.com.
|
||||
```
|
||||
|
||||
To get the result as JSON, also include the `Accept: application/json` header:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-H "x-ms-wait-for-response: true" \
|
||||
-H "Accept: application/json" \
|
||||
-d "12345"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "abc123def456",
|
||||
"workflowStatus": "Completed",
|
||||
"result": "Cancellation email sent for order 12345 to jerry@example.com."
|
||||
}
|
||||
```
|
||||
|
||||
In the function app logs, you will see the sequential execution of each executor:
|
||||
|
||||
```text
|
||||
|
||||
+22
@@ -7,6 +7,21 @@ Content-Type: text/plain
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order and wait for the result
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order and wait for the result (JSON response)
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
Accept: application/json
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Cancel an order with a custom run ID
|
||||
POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123
|
||||
Content-Type: text/plain
|
||||
@@ -19,6 +34,13 @@ Content-Type: text/plain
|
||||
|
||||
12345
|
||||
|
||||
### Get order status and wait for the result
|
||||
POST {{authority}}/api/workflows/OrderStatus/run
|
||||
Content-Type: text/plain
|
||||
x-ms-wait-for-response: true
|
||||
|
||||
12345
|
||||
|
||||
### Batch cancel orders with a complex JSON input
|
||||
POST {{authority}}/api/workflows/BatchCancelOrders/run
|
||||
Content-Type: application/json
|
||||
|
||||
+2
@@ -13,6 +13,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Invocations" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="OpenTelemetry.Api" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
|
||||
@@ -62,12 +62,10 @@ public static class Program
|
||||
}
|
||||
|
||||
var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken);
|
||||
foreach (var chatMessage in agentResponse.Messages)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\nAgent: {chatMessage.Text}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\nAgent: {agentResponse.Text}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace A2AServer;
|
||||
|
||||
internal static class HostAgentFactory
|
||||
{
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList<AITool>? tools = null)
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList<AITool>? tools = null)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
@@ -26,16 +26,16 @@ internal static class HostAgentFactory
|
||||
|
||||
AgentCard agentCard = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => GetInvoiceAgentCard(),
|
||||
"POLICY" => GetPolicyAgentCard(),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(),
|
||||
"INVOICE" => GetInvoiceAgentCard(agentUrls),
|
||||
"POLICY" => GetPolicyAgentCard(agentUrls),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
|
||||
return new(agent, agentCard);
|
||||
}
|
||||
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList<AITool>? tools = null)
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList<AITool>? tools = null)
|
||||
{
|
||||
AIAgent agent = new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
@@ -43,9 +43,9 @@ internal static class HostAgentFactory
|
||||
|
||||
AgentCard agentCard = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => GetInvoiceAgentCard(),
|
||||
"POLICY" => GetPolicyAgentCard(),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(),
|
||||
"INVOICE" => GetInvoiceAgentCard(agentUrls),
|
||||
"POLICY" => GetPolicyAgentCard(agentUrls),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ internal static class HostAgentFactory
|
||||
}
|
||||
|
||||
#region private
|
||||
private static AgentCard GetInvoiceAgentCard()
|
||||
private static AgentCard GetInvoiceAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
@@ -82,10 +82,11 @@ internal static class HostAgentFactory
|
||||
DefaultOutputModes = ["text"],
|
||||
Capabilities = capabilities,
|
||||
Skills = [invoiceQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentCard GetPolicyAgentCard()
|
||||
private static AgentCard GetPolicyAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
@@ -114,10 +115,11 @@ internal static class HostAgentFactory
|
||||
DefaultOutputModes = ["text"],
|
||||
Capabilities = capabilities,
|
||||
Skills = [policyQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentCard GetLogisticsAgentCard()
|
||||
private static AgentCard GetLogisticsAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
@@ -146,7 +148,29 @@ internal static class HostAgentFactory
|
||||
DefaultOutputModes = ["text"],
|
||||
Capabilities = capabilities,
|
||||
Skills = [logisticsQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<AgentInterface> CreateAgentInterfaces(string[] agentUrls)
|
||||
{
|
||||
List<AgentInterface> agentInterfaces = [];
|
||||
|
||||
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
|
||||
{
|
||||
Url = url,
|
||||
ProtocolBinding = ProtocolBindingNames.JsonRpc,
|
||||
ProtocolVersion = "1.0",
|
||||
}));
|
||||
|
||||
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
|
||||
{
|
||||
Url = url,
|
||||
ProtocolBinding = ProtocolBindingNames.HttpJson,
|
||||
ProtocolVersion = "1.0",
|
||||
}));
|
||||
|
||||
return agentInterfaces;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ for (var i = 0; i < args.Length; i++)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
var app = builder.Build();
|
||||
|
||||
var httpClient = app.Services.GetRequiredService<IHttpClientFactory>().CreateClient();
|
||||
var logger = app.Logger;
|
||||
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
@@ -38,14 +34,15 @@ IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
string? apiKey = configuration["OPENAI_API_KEY"];
|
||||
string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini";
|
||||
string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"];
|
||||
string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';');
|
||||
|
||||
var invoiceQueryPlugin = new InvoiceQuery();
|
||||
IList<AITool> tools =
|
||||
[
|
||||
[
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices),
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId),
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId)
|
||||
];
|
||||
];
|
||||
|
||||
AIAgent hostA2AAgent;
|
||||
AgentCard hostA2AAgentCard;
|
||||
@@ -54,9 +51,9 @@ if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
(hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
|
||||
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
}
|
||||
@@ -68,7 +65,7 @@ else if (!string.IsNullOrEmpty(apiKey))
|
||||
agentType, model, apiKey, "InvoiceAgent",
|
||||
"""
|
||||
You specialize in handling queries related to invoices.
|
||||
""", tools),
|
||||
""", agentUrls, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, apiKey, "PolicyAgent",
|
||||
"""
|
||||
@@ -84,7 +81,7 @@ else if (!string.IsNullOrEmpty(apiKey))
|
||||
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
|
||||
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
|
||||
template."
|
||||
"""),
|
||||
""", agentUrls),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, apiKey, "LogisticsAgent",
|
||||
"""
|
||||
@@ -95,7 +92,7 @@ else if (!string.IsNullOrEmpty(apiKey))
|
||||
Shipment number: SHPMT-SAP-001
|
||||
Item: TSHIRT-RED-L
|
||||
Quantity: 900
|
||||
"""),
|
||||
""", agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
}
|
||||
@@ -104,10 +101,12 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
var a2aTaskManager = app.MapA2A(
|
||||
hostA2AAgent,
|
||||
path: "/",
|
||||
agentCard: hostA2AAgentCard,
|
||||
taskManager => app.MapWellKnownAgentCard(taskManager, "/"));
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapA2AHttpJson(hostA2AAgent, "/");
|
||||
app.MapA2AJsonRpc(hostA2AAgent, "/");
|
||||
|
||||
app.MapWellKnownAgentCard(hostA2AAgentCard);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using A2A.AspNetCore;
|
||||
using AgentWebChat.AgentHost;
|
||||
using AgentWebChat.AgentHost.Custom;
|
||||
using AgentWebChat.AgentHost.Utilities;
|
||||
@@ -146,6 +145,9 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
|
||||
});
|
||||
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
@@ -154,17 +156,9 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents
|
||||
// Configure the HTTP request pipeline.
|
||||
app.UseExceptionHandler();
|
||||
|
||||
// attach a2a with simple message communication
|
||||
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
|
||||
app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
|
||||
{
|
||||
Name = "Knights and Knaves",
|
||||
Description = "An agent that helps you solve the knights and knaves puzzle.",
|
||||
Version = "1.0",
|
||||
|
||||
// Url can be not set, and SDK will help assign it.
|
||||
// Url = "http://localhost:5390/a2a/knights-and-knaves"
|
||||
});
|
||||
// Expose A2A servers over HTTP with JSON payloads
|
||||
app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate");
|
||||
app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
|
||||
|
||||
app.MapDevUI();
|
||||
|
||||
|
||||
@@ -43,20 +43,21 @@ internal sealed class A2AAgentClient : AgentClientBase
|
||||
{
|
||||
// Convert all messages to A2A parts and create a single message
|
||||
var parts = messages.ToParts();
|
||||
var a2aMessage = new AgentMessage
|
||||
var a2aMessage = new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.User,
|
||||
Role = Role.User,
|
||||
Parts = parts
|
||||
};
|
||||
|
||||
var messageSendParams = new MessageSendParams { Message = a2aMessage };
|
||||
var messageSendParams = new SendMessageRequest { Message = a2aMessage };
|
||||
var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken);
|
||||
|
||||
// Handle different response types
|
||||
if (a2aResponse is AgentMessage message)
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
|
||||
{
|
||||
var message = a2aResponse.Message!;
|
||||
var responseMessage = message.ToChatMessage();
|
||||
if (responseMessage is { Contents.Count: > 0 })
|
||||
{
|
||||
@@ -67,9 +68,10 @@ internal sealed class A2AAgentClient : AgentClientBase
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (a2aResponse is AgentTask agentTask)
|
||||
else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
|
||||
{
|
||||
// Manually convert AgentTask artifacts to ChatMessages since the extension method is internal
|
||||
var agentTask = a2aResponse.Task!;
|
||||
if (agentTask.Artifacts is not null)
|
||||
{
|
||||
foreach (var artifact in agentTask.Artifacts)
|
||||
|
||||
@@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses.
|
||||
| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting |
|
||||
| [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations |
|
||||
| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative |
|
||||
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A |
|
||||
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks |
|
||||
| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos |
|
||||
|
||||
## Getting Started
|
||||
|
||||
+14
-3
@@ -2,14 +2,22 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
|
||||
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- Suppress analyzer warnings for Aspire integration code -->
|
||||
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
|
||||
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework DevUI for Aspire</Title>
|
||||
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
|
||||
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
|
||||
</ItemGroup>
|
||||
@@ -22,4 +30,7 @@
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -28,7 +27,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
|
||||
|
||||
private readonly A2AClient _a2aClient;
|
||||
private readonly IA2AClient _a2aClient;
|
||||
private readonly string? _id;
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
@@ -42,7 +41,7 @@ public sealed class A2AAgent : AIAgent
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(a2aClient);
|
||||
|
||||
@@ -100,64 +99,47 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
A2AResponse? a2aResponse = null;
|
||||
|
||||
if (GetContinuationToken(messages, options) is { } token)
|
||||
{
|
||||
a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageSendParams sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
|
||||
|
||||
return this.ConvertToAgentResponse(agentTask);
|
||||
}
|
||||
|
||||
SendMessageRequest sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
|
||||
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
|
||||
};
|
||||
|
||||
SendMessageResponse a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
if (a2aResponse is AgentMessage message)
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
|
||||
{
|
||||
var message = a2aResponse.Message!;
|
||||
|
||||
UpdateSession(typedSession, message.ContextId);
|
||||
|
||||
return new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = message,
|
||||
Messages = [message.ToChatMessage()],
|
||||
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
return this.ConvertToAgentResponse(message);
|
||||
}
|
||||
|
||||
if (a2aResponse is AgentTask agentTask)
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
|
||||
{
|
||||
var agentTask = a2aResponse.Task!;
|
||||
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
|
||||
|
||||
var response = new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = agentTask.Id,
|
||||
FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
|
||||
RawRepresentation = agentTask,
|
||||
Messages = agentTask.ToChatMessages() ?? [],
|
||||
ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
|
||||
AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
|
||||
if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages)
|
||||
{
|
||||
response.Messages = taskMessages;
|
||||
}
|
||||
|
||||
return response;
|
||||
return this.ConvertToAgentResponse(agentTask);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
|
||||
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -169,59 +151,61 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
ConfiguredCancelableAsyncEnumerable<SseItem<A2AEvent>> a2aSseEvents;
|
||||
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
|
||||
|
||||
if (options?.ContinuationToken is not null)
|
||||
if (GetContinuationToken(messages, options) is { } token)
|
||||
{
|
||||
// Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations.
|
||||
// The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream
|
||||
// from the beginning, but it does not define stream resumption from a specific point in the stream.
|
||||
// Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification,
|
||||
// and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to
|
||||
// the existing ones or reconnect the stream and obtain all updates again.
|
||||
// For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764
|
||||
throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet.");
|
||||
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
MessageSendParams sendParams = new()
|
||||
else
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
SendMessageRequest sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
string? contextId = null;
|
||||
string? taskId = null;
|
||||
|
||||
await foreach (var sseEvent in a2aSseEvents)
|
||||
await foreach (var streamResponse in streamEvents)
|
||||
{
|
||||
if (sseEvent.Data is AgentMessage message)
|
||||
switch (streamResponse.PayloadCase)
|
||||
{
|
||||
contextId = message.ContextId;
|
||||
case StreamResponseCase.Message:
|
||||
var message = streamResponse.Message!;
|
||||
contextId = message.ContextId;
|
||||
yield return this.ConvertToAgentResponseUpdate(message);
|
||||
break;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(message);
|
||||
}
|
||||
else if (sseEvent.Data is AgentTask task)
|
||||
{
|
||||
contextId = task.ContextId;
|
||||
taskId = task.Id;
|
||||
case StreamResponseCase.Task:
|
||||
var task = streamResponse.Task!;
|
||||
contextId = task.ContextId;
|
||||
taskId = task.Id;
|
||||
yield return this.ConvertToAgentResponseUpdate(task);
|
||||
break;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(task);
|
||||
}
|
||||
else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent)
|
||||
{
|
||||
contextId = taskUpdateEvent.ContextId;
|
||||
taskId = taskUpdateEvent.TaskId;
|
||||
case StreamResponseCase.StatusUpdate:
|
||||
var statusUpdate = streamResponse.StatusUpdate!;
|
||||
contextId = statusUpdate.ContextId;
|
||||
taskId = statusUpdate.TaskId;
|
||||
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
|
||||
break;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}");
|
||||
case StreamResponseCase.ArtifactUpdate:
|
||||
var artifactUpdate = streamResponse.ArtifactUpdate!;
|
||||
contextId = artifactUpdate.ContextId;
|
||||
taskId = artifactUpdate.TaskId;
|
||||
yield return this.ConvertToAgentResponseUpdate(artifactUpdate);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +224,7 @@ public sealed class A2AAgent : AIAgent
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
=> base.GetService(serviceType, serviceKey)
|
||||
?? (serviceType == typeof(A2AClient) ? this._a2aClient
|
||||
?? (serviceType == typeof(IA2AClient) ? this._a2aClient
|
||||
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
|
||||
: null);
|
||||
|
||||
@@ -264,6 +248,75 @@ public sealed class A2AAgent : AIAgent
|
||||
return typedSession;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to task updates, falling back to <see cref="A2AClient.GetTaskAsync"/>
|
||||
/// when the task has already reached a terminal state and the server responds with
|
||||
/// <see cref="A2AErrorCode.UnsupportedOperation"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed,
|
||||
/// canceled, or rejected) results in an <c>UnsupportedOperationError</c>.
|
||||
/// See: <see href="https://a2a-protocol.org/latest/specification/#332-error-handling"/>.
|
||||
/// </remarks>
|
||||
private async IAsyncEnumerable<StreamResponse> SubscribeToTaskWithFallbackAsync(
|
||||
string taskId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken);
|
||||
|
||||
var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken);
|
||||
|
||||
// yield return cannot appear inside a try block that has catch clauses,
|
||||
// so we manually advance the enumerator within try/catch and yield outside it.
|
||||
// The outer try/finally (no catch) is allowed to contain yield return in C#.
|
||||
StreamResponse? fallbackResponse = null;
|
||||
bool disposed = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
bool hasNext;
|
||||
try
|
||||
{
|
||||
hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation)
|
||||
{
|
||||
this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message);
|
||||
|
||||
// Dispose the enumerator before the fallback call to release the HTTP/SSE connection.
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
disposed = true;
|
||||
|
||||
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
fallbackResponse = new StreamResponse { Task = agentTask };
|
||||
break;
|
||||
}
|
||||
|
||||
if (!hasNext)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
yield return enumerator.Current;
|
||||
}
|
||||
|
||||
if (fallbackResponse is not null)
|
||||
{
|
||||
yield return fallbackResponse;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!disposed)
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
|
||||
{
|
||||
if (session is null)
|
||||
@@ -284,7 +337,7 @@ public sealed class A2AAgent : AIAgent
|
||||
session.TaskId = taskId;
|
||||
}
|
||||
|
||||
private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
|
||||
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
@@ -324,7 +377,34 @@ public sealed class A2AAgent : AIAgent
|
||||
return null;
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message)
|
||||
private AgentResponse ConvertToAgentResponse(Message message)
|
||||
{
|
||||
return new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = message,
|
||||
Messages = [message.ToChatMessage()],
|
||||
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponse ConvertToAgentResponse(AgentTask task)
|
||||
{
|
||||
return new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = task.Id,
|
||||
FinishReason = MapTaskStateToFinishReason(task.Status.State),
|
||||
RawRepresentation = task,
|
||||
Messages = task.ToChatMessages() ?? [],
|
||||
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
|
||||
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message)
|
||||
{
|
||||
return new AgentResponseUpdate
|
||||
{
|
||||
@@ -349,32 +429,35 @@ public sealed class A2AAgent : AIAgent
|
||||
RawRepresentation = task,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = task.ToAIContents(),
|
||||
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
|
||||
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent)
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent)
|
||||
{
|
||||
AgentResponseUpdate responseUpdate = new()
|
||||
return new AgentResponseUpdate
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = taskUpdateEvent.TaskId,
|
||||
RawRepresentation = taskUpdateEvent,
|
||||
ResponseId = statusUpdateEvent.TaskId,
|
||||
RawRepresentation = statusUpdateEvent,
|
||||
Role = ChatRole.Assistant,
|
||||
AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
|
||||
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent)
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent)
|
||||
{
|
||||
return new AgentResponseUpdate
|
||||
{
|
||||
responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents();
|
||||
responseUpdate.RawRepresentation = artifactUpdateEvent;
|
||||
}
|
||||
else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent)
|
||||
{
|
||||
responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State);
|
||||
}
|
||||
|
||||
return responseUpdate;
|
||||
AgentId = this.Id,
|
||||
ResponseId = artifactUpdateEvent.TaskId,
|
||||
RawRepresentation = artifactUpdateEvent,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = artifactUpdateEvent.Artifact.ToAIContents(),
|
||||
AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
|
||||
|
||||
@@ -34,4 +34,17 @@ internal static partial class A2AAgentLogMessages
|
||||
string methodName,
|
||||
string agentId,
|
||||
string? agentName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs <see cref="A2AAgent"/> falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")]
|
||||
public static partial void LogA2ASubscribeToTaskFallback(
|
||||
this ILogger logger,
|
||||
string agentId,
|
||||
string? agentName,
|
||||
string taskId,
|
||||
string errorMessage);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ internal class A2AContinuationToken : ResponseContinuationToken
|
||||
{
|
||||
case "taskId":
|
||||
reader.Read();
|
||||
taskId = reader.GetString()!;
|
||||
taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value.");
|
||||
break;
|
||||
default:
|
||||
throw new JsonException($"Unrecognized property '{propertyName}'.");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,12 +24,15 @@ public static class A2AAgentCardExtensions
|
||||
/// </remarks>
|
||||
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="options">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
|
||||
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
// Create the A2A client using the agent URL from the card.
|
||||
var a2aClient = new A2AClient(new Uri(card.Url), httpClient);
|
||||
var a2aClient = A2AClientFactory.Create(card, httpClient, options);
|
||||
|
||||
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,18 @@ public static class A2ACardResolverExtensions
|
||||
/// </remarks>
|
||||
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="options">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return agentCard.AsAIAgent(httpClient, loggerFactory);
|
||||
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="A2AClient"/>
|
||||
/// Provides extension methods for <see cref="IA2AClient"/>
|
||||
/// to simplify the creation of A2A agents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -29,12 +29,12 @@ public static class A2AClientExtensions
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="A2AClient" /> to use for the agent.</param>
|
||||
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
new A2AAgent(client, id, name, description, loggerFactory);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI;
|
||||
/// </summary>
|
||||
internal static class ChatMessageExtensions
|
||||
{
|
||||
internal static AgentMessage ToA2AMessage(this IEnumerable<ChatMessage> messages)
|
||||
internal static Message ToA2AMessage(this IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
List<Part> allParts = [];
|
||||
|
||||
@@ -23,10 +23,10 @@ internal static class ChatMessageExtensions
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentMessage
|
||||
return new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = MessageRole.User,
|
||||
Role = Role.User,
|
||||
Parts = allParts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
|
||||
namespace Azure.AI.Projects;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
|
||||
/// Foundry toolbox definitions as server-side tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
|
||||
/// allowing a single call on the project client to retrieve tools ready for use
|
||||
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class AIProjectClientToolboxExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
|
||||
this AIProjectClient projectClient,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectClient);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
|
||||
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
|
||||
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
|
||||
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
|
||||
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
|
||||
/// handles tool execution — the agent process does not invoke tools locally.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryToolbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically (requires an additional API call).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
|
||||
/// suitable for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
|
||||
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
|
||||
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
|
||||
/// platform handles their execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
|
||||
/// that the toolbox API returns but the Responses API rejects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
|
||||
{
|
||||
Throw.IfNull(toolboxVersion);
|
||||
|
||||
if (toolboxVersion.Tools?.Any() != true)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return toolboxVersion.Tools
|
||||
.Select(SanitizeAndConvert)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
|
||||
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
|
||||
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
|
||||
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
|
||||
/// these decoration fields for non-function tools. Function tools keep them since
|
||||
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
|
||||
/// </remarks>
|
||||
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
|
||||
{
|
||||
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
|
||||
var node = JsonNode.Parse(toolJson.ToString());
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var toolType = obj["type"]?.GetValue<string>();
|
||||
|
||||
// Function tools need name/description — don't strip
|
||||
if (toolType is "function" or "custom")
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
// Strip decoration fields that the Responses API rejects
|
||||
bool modified = false;
|
||||
modified |= obj.Remove("name");
|
||||
modified |= obj.Remove("description");
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var sanitizedJson = obj.ToJsonString();
|
||||
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
|
||||
return sanitizedTool.AsAITool();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version,
|
||||
AgentAdministrationClientOptions? clientOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static AgentToolboxes CreateToolboxClient(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AgentAdministrationClientOptions? clientOptions = null)
|
||||
{
|
||||
clientOptions ??= new AgentAdministrationClientOptions();
|
||||
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
|
||||
return adminClient.GetAgentToolboxes();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
|
||||
AgentToolboxes toolboxClient,
|
||||
string name,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (version is null)
|
||||
{
|
||||
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
version = record.Value.DefaultVersion
|
||||
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
|
||||
}
|
||||
|
||||
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -237,7 +237,7 @@ internal static class InputConverter
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
|
||||
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -332,7 +332,7 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
{
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
|
||||
+1
@@ -34,6 +34,7 @@
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -251,16 +251,25 @@ internal static class OutputConverter
|
||||
var outputTokens = details.OutputTokenCount ?? 0;
|
||||
var totalTokens = details.TotalTokenCount ?? 0;
|
||||
|
||||
var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false
|
||||
? cached : 0;
|
||||
var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false
|
||||
? reasoning : 0;
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
inputTokens += existing.InputTokens;
|
||||
outputTokens += existing.OutputTokens;
|
||||
totalTokens += existing.TotalTokens;
|
||||
cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0;
|
||||
reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0;
|
||||
}
|
||||
|
||||
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
|
||||
return new ResponseUsage(
|
||||
inputTokens: inputTokens,
|
||||
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
|
||||
outputTokens: outputTokens,
|
||||
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
|
||||
totalTokens: totalTokens);
|
||||
}
|
||||
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using A2A.AspNetCore;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.AspNetCore.Builder;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for mapping A2A protocol endpoints for AI agents.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class A2AEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for the agent.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
return endpoints.MapA2AHttpJson(agentBuilder.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
|
||||
|
||||
return endpoints.MapA2AHttpJson(agent.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No A2AServer is registered for agent '{agentName}'. " +
|
||||
$"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
|
||||
|
||||
// TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the
|
||||
// agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a
|
||||
// GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed
|
||||
// by any agent - returning a stub agent card here is safe.
|
||||
var stubAgentCard = new AgentCard { Name = "A2A Agent" };
|
||||
|
||||
return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for the agent.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
return endpoints.MapA2AJsonRpc(agentBuilder.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
|
||||
|
||||
return endpoints.MapA2AJsonRpc(agent.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No A2AServer is registered for agent '{agentName}'. " +
|
||||
$"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
|
||||
|
||||
return endpoints.MapA2A(a2aServer, path);
|
||||
}
|
||||
}
|
||||
-385
@@ -1,385 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using A2A.AspNetCore;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.A2A;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.AspNetCore.Builder;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
=> endpoints.MapA2A(agentBuilder, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
=> endpoints.MapA2A(agentName, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agentBuilder, path, agentCard, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
|
||||
=> endpoints.MapA2A(agent, path, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
|
||||
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
|
||||
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
|
||||
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
|
||||
|
||||
configureTaskManager(taskManager);
|
||||
return endpointConventionBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
|
||||
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <param name="agentCard">Agent card info to return on query.</param>
|
||||
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
|
||||
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
/// <remarks>
|
||||
/// This method can be used to access A2A agents that support the
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
|
||||
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
|
||||
|
||||
configureTaskManager(taskManager);
|
||||
|
||||
return endpointConventionBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
|
||||
/// TaskManager should be preconfigured before calling this method.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="taskManager">Pre-configured A2A TaskManager to use for A2A endpoints handling.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path)
|
||||
{
|
||||
// note: current SDK version registers multiple `.well-known/agent.json` handlers here.
|
||||
// it makes app return HTTP 500, but will be fixed once new A2A SDK is released.
|
||||
// see https://github.com/microsoft/agent-framework/issues/476 for details
|
||||
A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path);
|
||||
return endpoints.MapHttpA2A(taskManager, path);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,9 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.A2A.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- RT0002: Microsoft.Agents.AI.Hosting.A2A is intentionally referenced as a transitive dependency
|
||||
so that consumers of this package automatically get the AddA2AServer registration extensions. -->
|
||||
<NoWarn>$(NoWarn);RT0002</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
@@ -13,7 +16,7 @@
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A.AspNetCore" />
|
||||
</ItemGroup>
|
||||
@@ -21,7 +24,7 @@
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IAgentHandler"/> implementation that bridges an <see cref="AIAgent"/> to the
|
||||
/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to
|
||||
/// the underlying agent and translating responses into A2A events.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
internal sealed class A2AAgentHandler : IAgentHandler
|
||||
{
|
||||
private readonly AIHostAgent _hostAgent;
|
||||
private readonly AgentRunMode _runMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="A2AAgentHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostAgent">The hosted agent that provides the execution logic.</param>
|
||||
/// <param name="runMode">Controls whether the agent runs in background mode.</param>
|
||||
public A2AAgentHandler(
|
||||
AIHostAgent hostAgent,
|
||||
AgentRunMode runMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hostAgent);
|
||||
ArgumentNullException.ThrowIfNull(runMode);
|
||||
|
||||
this._hostAgent = hostAgent;
|
||||
this._runMode = runMode;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
// Handle task updates
|
||||
if (context.IsContinuation)
|
||||
{
|
||||
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle messages received via streaming endpoint
|
||||
if (context.StreamingResponse)
|
||||
{
|
||||
return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
// Handle new messages received via non-streaming endpoint
|
||||
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
|
||||
await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
|
||||
}
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
// Decide whether to run in background based on user preferences and agent capabilities
|
||||
var decisionContext = new A2ARunDecisionContext(context);
|
||||
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = context.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
AgentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
// Return a lightweight message response (no task lifecycle needed).
|
||||
var message = CreateMessageFromResponse(contextId, response);
|
||||
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Long-running operation: emit task lifecycle events.
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Message? progressMessage = response.Messages.Count > 0
|
||||
? CreateMessageFromResponse(contextId, response)
|
||||
: null;
|
||||
|
||||
await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
|
||||
}
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
var options = context.Metadata is { Count: > 0 }
|
||||
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var message = CreateMessageFromUpdate(contextId, update);
|
||||
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
List<ChatMessage> chatMessages = ExtractChatMessagesFromTaskHistory(context.Task);
|
||||
|
||||
var decisionContext = new A2ARunDecisionContext(context);
|
||||
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = context.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
AgentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
// Complete the task with an artifact containing the response.
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still working: emit progress status.
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
|
||||
Message? progressMessage = response.Messages.Count > 0
|
||||
? CreateMessageFromResponse(contextId, response)
|
||||
: null;
|
||||
|
||||
await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static Message CreateMessageFromResponse(string contextId, AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.Agent,
|
||||
Parts = response.Messages.ToParts(),
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
|
||||
new()
|
||||
{
|
||||
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.Agent,
|
||||
Parts = update.ToParts(),
|
||||
Metadata = update.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
|
||||
{
|
||||
if (agentTask?.History is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
|
||||
foreach (var message in agentTask.History)
|
||||
{
|
||||
chatMessages.Add(message.ToChatMessage());
|
||||
}
|
||||
|
||||
return chatMessages;
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
/// </summary>
|
||||
public sealed class A2ARunDecisionContext
|
||||
{
|
||||
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
|
||||
internal A2ARunDecisionContext(RequestContext requestContext)
|
||||
{
|
||||
this.MessageSendParams = messageSendParams;
|
||||
this.RequestContext = requestContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameters of the incoming A2A message that triggered this run.
|
||||
/// Gets the request context of the incoming A2A request that triggered this run.
|
||||
/// </summary>
|
||||
public MessageSendParams MessageSendParams { get; }
|
||||
public RequestContext RequestContext { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring A2A server registration.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public sealed class A2AServerRegistrationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.
|
||||
/// </remarks>
|
||||
public AgentRunMode? AgentRunMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the A2A server options used to configure the underlying <see cref="A2AServer"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, no custom server options are applied.
|
||||
/// </remarks>
|
||||
public A2AServerOptions? ServerOptions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.A2A;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for registering A2A server instances in the dependency injection container.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class A2AServerServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the agent name
|
||||
/// specified in the <paramref name="agentBuilder"/>. This method only registers the server; to expose it
|
||||
/// as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping
|
||||
/// methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions);
|
||||
|
||||
return agentBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the specified
|
||||
/// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
|
||||
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.AddA2AServer(agentName, configureOptions);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
|
||||
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
|
||||
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
|
||||
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.AddA2AServer(agent, configureOptions);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the specified
|
||||
/// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
|
||||
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add the A2A server to.</param>
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
|
||||
|
||||
A2AServerRegistrationOptions? options = null;
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
options = new A2AServerRegistrationOptions();
|
||||
configureOptions(options);
|
||||
}
|
||||
|
||||
services.AddKeyedSingleton(agentName, (sp, _) =>
|
||||
{
|
||||
var agent = sp.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return CreateA2AServer(sp, agent, options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
|
||||
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
|
||||
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
|
||||
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add the A2A server to.</param>
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
|
||||
|
||||
A2AServerRegistrationOptions? options = null;
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
options = new A2AServerRegistrationOptions();
|
||||
configureOptions(options);
|
||||
}
|
||||
|
||||
services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
|
||||
{
|
||||
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
|
||||
if (agentHandler is null)
|
||||
{
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
|
||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
var loggerFactory = serviceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
|
||||
|
||||
return new A2AServer(
|
||||
agentHandler,
|
||||
taskStore,
|
||||
new ChannelEventNotifier(),
|
||||
loggerFactory.CreateLogger<A2AServer>(),
|
||||
options?.ServerOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class AIAgentExtensions
|
||||
{
|
||||
// Metadata key used to store continuation tokens for long-running background operations
|
||||
// in the AgentTask.Metadata dictionary, persisted by the task store.
|
||||
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
|
||||
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
|
||||
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
|
||||
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
|
||||
/// <param name="runMode">Controls the response behavior of the agent run.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
|
||||
/// <returns>The configured <see cref="TaskManager"/>.</returns>
|
||||
public static ITaskManager MapA2A(
|
||||
this AIAgent agent,
|
||||
ITaskManager? taskManager = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
AgentSessionStore? agentSessionStore = null,
|
||||
AgentRunMode? runMode = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(agent.Name);
|
||||
|
||||
runMode ??= AgentRunMode.DisallowBackground;
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
|
||||
|
||||
taskManager ??= new TaskManager();
|
||||
|
||||
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
|
||||
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
|
||||
|
||||
// OnMessageReceived handles both message-only and task-based flows.
|
||||
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
|
||||
// so we consolidate all initial message handling here and return either
|
||||
// an AgentMessage or AgentTask depending on the agent response.
|
||||
// When the agent returns a ContinuationToken (long-running operation), a task is
|
||||
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
|
||||
// See https://github.com/a2aproject/a2a-dotnet/issues/275
|
||||
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
|
||||
|
||||
// Task flow for subsequent updates and cancellations
|
||||
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
|
||||
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
|
||||
|
||||
return taskManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
|
||||
/// <param name="agentCard">The agent card to return on query.</param>
|
||||
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
|
||||
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
|
||||
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
|
||||
/// <param name="runMode">Controls the response behavior of the agent run.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
|
||||
/// <returns>The configured <see cref="TaskManager"/>.</returns>
|
||||
public static ITaskManager MapA2A(
|
||||
this AIAgent agent,
|
||||
AgentCard agentCard,
|
||||
ITaskManager? taskManager = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
AgentSessionStore? agentSessionStore = null,
|
||||
AgentRunMode? runMode = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
|
||||
|
||||
taskManager.OnAgentCardQuery += (context, query) =>
|
||||
{
|
||||
// A2A SDK assigns the url on its own
|
||||
// we can help user if they did not set Url explicitly.
|
||||
if (string.IsNullOrEmpty(agentCard.Url))
|
||||
{
|
||||
agentCard.Url = context.TrimEnd('/');
|
||||
}
|
||||
|
||||
return Task.FromResult(agentCard);
|
||||
};
|
||||
return taskManager;
|
||||
}
|
||||
|
||||
private static async Task<A2AResponse> OnMessageReceivedAsync(
|
||||
MessageSendParams messageSendParams,
|
||||
AIHostAgent hostAgent,
|
||||
AgentRunMode runMode,
|
||||
ITaskManager taskManager,
|
||||
JsonSerializerOptions continuationTokenJsonOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// AIAgent does not support resuming from arbitrary prior tasks.
|
||||
// Throw explicitly so the client gets a clear error rather than a response
|
||||
// that silently ignores the referenced task context.
|
||||
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
|
||||
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
|
||||
}
|
||||
|
||||
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Decide whether to run in background based on user preferences and agent capabilities
|
||||
var decisionContext = new A2ARunDecisionContext(messageSendParams);
|
||||
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = messageSendParams.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await hostAgent.RunAsync(
|
||||
messageSendParams.ToChatMessages(),
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
return CreateMessageFromResponse(contextId, response);
|
||||
}
|
||||
|
||||
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
|
||||
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
return agentTask;
|
||||
}
|
||||
|
||||
private static async Task OnTaskUpdatedAsync(
|
||||
AgentTask agentTask,
|
||||
AIHostAgent hostAgent,
|
||||
ITaskManager taskManager,
|
||||
JsonSerializerOptions continuationTokenJsonOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Discard any stale continuation token — the incoming user message supersedes
|
||||
// any previous background operation. AF agents don't support updating existing
|
||||
// background responses (long-running operations); we start a fresh run from the
|
||||
// existing session using the full chat history (which includes the new message).
|
||||
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
|
||||
|
||||
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await hostAgent.RunAsync(
|
||||
ExtractChatMessagesFromTaskHistory(agentTask),
|
||||
session: session,
|
||||
options: new AgentRunOptions { AllowBackgroundResponses = true },
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is not null)
|
||||
{
|
||||
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
|
||||
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await taskManager.UpdateStatusAsync(
|
||||
agentTask.Id,
|
||||
TaskState.Failed,
|
||||
final: true,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
|
||||
{
|
||||
// Remove the continuation token from metadata if present.
|
||||
// The task has already been marked as cancelled by the TaskManager.
|
||||
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.Agent,
|
||||
Parts = response.Messages.ToParts(),
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
// Task outputs should be returned as artifacts rather than messages:
|
||||
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
|
||||
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
Parts = response.Messages.ToParts(),
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
private static async Task<AgentTask> InitializeTaskAsync(
|
||||
string contextId,
|
||||
AgentMessage originalMessage,
|
||||
ITaskManager taskManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Add the original user message to the task history.
|
||||
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
|
||||
agentTask.History ??= [];
|
||||
agentTask.History.Add(originalMessage);
|
||||
|
||||
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
|
||||
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return agentTask;
|
||||
}
|
||||
|
||||
private static void StoreContinuationToken(
|
||||
AgentTask agentTask,
|
||||
ResponseContinuationToken token,
|
||||
JsonSerializerOptions continuationTokenJsonOptions)
|
||||
{
|
||||
// Serialize the continuation token into the task's metadata so it survives
|
||||
// across requests and is cleaned up with the task itself.
|
||||
agentTask.Metadata ??= [];
|
||||
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
|
||||
token,
|
||||
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
}
|
||||
|
||||
private static async Task TransitionToWorkingAsync(
|
||||
string taskId,
|
||||
string contextId,
|
||||
AgentResponse response,
|
||||
ITaskManager taskManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Include any intermediate progress messages from the response as a status message.
|
||||
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
|
||||
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task CompleteWithArtifactAsync(
|
||||
string taskId,
|
||||
AgentResponse response,
|
||||
ITaskManager taskManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var artifact = CreateArtifactFromResponse(response);
|
||||
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
|
||||
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
|
||||
{
|
||||
if (agentTask.History is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
|
||||
foreach (var message in agentTask.History)
|
||||
{
|
||||
chatMessages.Add(message.ToChatMessage());
|
||||
}
|
||||
|
||||
return chatMessages;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
@@ -28,7 +29,7 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
|
||||
/// Disallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
|
||||
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
|
||||
/// </summary>
|
||||
public static AgentRunMode DisallowBackground => new(MessageValue);
|
||||
@@ -79,18 +80,22 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
|
||||
}
|
||||
|
||||
// No delegate provided — fall back to "message" behavior.
|
||||
return ValueTask.FromResult(true);
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(AgentRunMode? other) =>
|
||||
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
|
||||
other is not null
|
||||
&& string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase)
|
||||
&& ReferenceEquals(this._runInBackground, other._runInBackground);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
|
||||
public override int GetHashCode() => HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(this._value),
|
||||
RuntimeHelpers.GetHashCode(this._runInBackground));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => this._value;
|
||||
|
||||
@@ -8,6 +8,26 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
|
||||
internal static class MessageConverter
|
||||
{
|
||||
public static List<Part> ToParts(this AgentResponseUpdate update)
|
||||
{
|
||||
if (update is null || update.Contents is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var parts = new List<Part>();
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
var part = content.ToPart();
|
||||
if (part is not null)
|
||||
{
|
||||
parts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public static List<Part> ToParts(this IList<ChatMessage> chatMessages)
|
||||
{
|
||||
if (chatMessages is null || chatMessages.Count == 0)
|
||||
@@ -31,21 +51,21 @@ internal static class MessageConverter
|
||||
return parts;
|
||||
}
|
||||
/// <summary>
|
||||
/// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects.
|
||||
/// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects.
|
||||
/// </summary>
|
||||
/// <param name="messageSendParams">The A2A message send parameters to convert.</param>
|
||||
/// <param name="sendMessageRequest">The A2A send message request to convert.</param>
|
||||
/// <returns>A read-only collection of ChatMessage objects.</returns>
|
||||
public static List<ChatMessage> ToChatMessages(this MessageSendParams messageSendParams)
|
||||
public static List<ChatMessage> ToChatMessages(this SendMessageRequest sendMessageRequest)
|
||||
{
|
||||
if (messageSendParams is null)
|
||||
if (sendMessageRequest is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ChatMessage>();
|
||||
if (messageSendParams.Message?.Parts is not null)
|
||||
if (sendMessageRequest.Message?.Parts is not null)
|
||||
{
|
||||
result.Add(messageSendParams.Message.ToChatMessage());
|
||||
result.Add(sendMessageRequest.Message.ToChatMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -21,6 +21,8 @@ internal static class BuiltInFunctions
|
||||
internal const string HttpPrefix = "http-";
|
||||
internal const string McpToolPrefix = "mcptool-";
|
||||
|
||||
private const string WaitForResponseHeaderName = "x-ms-wait-for-response";
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
@@ -62,6 +64,11 @@ internal static class BuiltInFunctions
|
||||
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
|
||||
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
|
||||
|
||||
if (ShouldWaitForResponse(req, defaultValue: false))
|
||||
{
|
||||
return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId);
|
||||
}
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
|
||||
return response;
|
||||
@@ -304,15 +311,7 @@ internal static class BuiltInFunctions
|
||||
}
|
||||
|
||||
// Check if we should wait for response (default is true)
|
||||
bool waitForResponse = true;
|
||||
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
|
||||
{
|
||||
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
|
||||
{
|
||||
waitForResponse = parsedValue;
|
||||
}
|
||||
}
|
||||
bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true);
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName);
|
||||
|
||||
@@ -428,6 +427,95 @@ internal static class BuiltInFunctions
|
||||
return metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a workflow orchestration to complete and returns an appropriate HTTP response.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData> WaitForWorkflowCompletionAsync(
|
||||
HttpRequestData req,
|
||||
DurableTaskClient client,
|
||||
FunctionContext context,
|
||||
string instanceId)
|
||||
{
|
||||
bool acceptsJson = AcceptsJson(req);
|
||||
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: context.CancellationToken);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound,
|
||||
$"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson);
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error";
|
||||
HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
await failedResponse.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
failedResponse.Headers.Add("Content-Type", "text/plain");
|
||||
await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken);
|
||||
}
|
||||
|
||||
return failedResponse;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError,
|
||||
$"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson);
|
||||
}
|
||||
|
||||
string? result = metadata.ReadOutputAs<DurableWorkflowResult>()?.Result;
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (acceptsJson)
|
||||
{
|
||||
JsonElement? resultElement = null;
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(result);
|
||||
resultElement = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Result is a plain string (not valid JSON) — serialize it as a JSON string element.
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer))
|
||||
{
|
||||
writer.WriteStringValue(result);
|
||||
}
|
||||
|
||||
using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory);
|
||||
resultElement = fallbackDoc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
await response.WriteAsJsonAsync(
|
||||
new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement),
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Headers.Add("Content-Type", "text/plain");
|
||||
await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
@@ -435,18 +523,18 @@ internal static class BuiltInFunctions
|
||||
/// <param name="context">The function context.</param>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <param name="errorMessage">The error message.</param>
|
||||
/// <param name="acceptsJson">Optional pre-computed value indicating whether the client accepts JSON. When <see langword="null"/>, the value is determined from the request's <c>Accept</c> header.</param>
|
||||
/// <returns>The HTTP response data containing the error.</returns>
|
||||
private static async Task<HttpResponseData> CreateErrorResponseAsync(
|
||||
HttpRequestData req,
|
||||
FunctionContext context,
|
||||
HttpStatusCode statusCode,
|
||||
string errorMessage)
|
||||
string errorMessage,
|
||||
bool? acceptsJson = null)
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (acceptsJson ?? AcceptsJson(req))
|
||||
{
|
||||
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
|
||||
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
|
||||
@@ -479,10 +567,7 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(statusCode);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (AcceptsJson(req))
|
||||
{
|
||||
AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse);
|
||||
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
|
||||
@@ -511,10 +596,7 @@ internal static class BuiltInFunctions
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
response.Headers.Add("x-ms-thread-id", sessionId);
|
||||
|
||||
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (acceptsJson)
|
||||
if (AcceptsJson(req))
|
||||
{
|
||||
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId);
|
||||
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
|
||||
@@ -528,6 +610,34 @@ internal static class BuiltInFunctions
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the caller has requested waiting for the workflow/agent to complete,
|
||||
/// as indicated by the <c>x-ms-wait-for-response</c> header. Falls back to <paramref name="defaultValue"/>
|
||||
/// when the header is absent or not a valid boolean.
|
||||
/// </summary>
|
||||
private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue)
|
||||
{
|
||||
if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable<string>? values) &&
|
||||
bool.TryParse(values.FirstOrDefault(), out bool parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the request accepts the <c>application/json</c> media type.
|
||||
/// </summary>
|
||||
private static bool AcceptsJson(HttpRequestData req)
|
||||
{
|
||||
return req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues
|
||||
.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(v => v.Split(';', 2)[0].Trim())
|
||||
.Contains("application/json", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Check if the function name starts with the HttpPrefix
|
||||
@@ -591,6 +701,19 @@ internal static class BuiltInFunctions
|
||||
[property: JsonPropertyName("eventName")] string? EventName,
|
||||
[property: JsonPropertyName("response")] JsonElement Response);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a workflow run response when waiting for completion.
|
||||
/// </summary>
|
||||
/// <param name="RunId">The orchestration run ID.</param>
|
||||
/// <param name="WorkflowStatus">The orchestration runtime status (e.g., "Completed", "Failed").</param>
|
||||
/// <param name="Result">The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings.</param>
|
||||
/// <param name="Error">An optional error message when the workflow has failed.</param>
|
||||
private sealed record WorkflowRunResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("workflowStatus")] string WorkflowStatus,
|
||||
[property: JsonPropertyName("result")] JsonElement? Result,
|
||||
[property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321))
|
||||
- Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768))
|
||||
- Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
|
||||
/// </summary>
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTTP request handler for executing <c>HttpRequestAction</c> actions within workflows.
|
||||
/// If not set, HTTP request actions will fail with an appropriate error message.
|
||||
/// </summary>
|
||||
public IHttpRequestHandler? HttpRequestHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the configuration settings for the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IHttpRequestHandler"/> built on <see cref="HttpClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler supports per-request authentication via an optional <c>httpClientProvider</c> callback that
|
||||
/// returns a pre-configured <see cref="HttpClient"/> for a given request (e.g. authenticated, custom handler).
|
||||
/// When the provider returns <see langword="null"/>, or no provider is supplied, a shared internal <see cref="HttpClient"/>
|
||||
/// is used.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The handler applies the per-request <see cref="HttpRequestInfo.Timeout"/> using a linked <see cref="CancellationTokenSource"/>
|
||||
/// so it does not mutate <see cref="HttpClient.Timeout"/> on shared instances.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
|
||||
{
|
||||
private readonly Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Lazy<HttpClient> _ownedHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses an
|
||||
/// internally owned <see cref="HttpClient"/> for all requests. The internal client is disposed
|
||||
/// when <see cref="DisposeAsync"/> is called.
|
||||
/// </summary>
|
||||
public DefaultHttpRequestHandler()
|
||||
: this(httpClientProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses the
|
||||
/// supplied <see cref="HttpClient"/> for all requests.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">
|
||||
/// The <see cref="HttpClient"/> to use for all requests. The caller retains ownership of this
|
||||
/// instance; it is not disposed by <see cref="DisposeAsync"/>.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is <see langword="null"/>.</exception>
|
||||
public DefaultHttpRequestHandler(HttpClient httpClient)
|
||||
: this(CreateSingleClientProvider(httpClient))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that selects
|
||||
/// an <see cref="HttpClient"/> per request via a caller-supplied callback — for example, to route
|
||||
/// different URLs through differently authenticated clients.
|
||||
/// </summary>
|
||||
/// <param name="httpClientProvider">
|
||||
/// An optional callback invoked for each request. The callback receives the <see cref="HttpRequestInfo"/>
|
||||
/// and should return a pre-configured <see cref="HttpClient"/> (e.g. with authentication or a custom
|
||||
/// transport). Return <see langword="null"/> to fall back to the handler's shared internal
|
||||
/// <see cref="HttpClient"/>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Ownership</b>: the caller is solely responsible for the lifetime of clients returned by this
|
||||
/// callback. <see cref="DefaultHttpRequestHandler"/> will <b>not</b> dispose provider-returned
|
||||
/// clients; only the handler's internally owned fallback client is disposed by <see cref="DisposeAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reuse</b>: callers are expected to cache and reuse clients (for example, keyed by base URL or
|
||||
/// auth scope) across requests. Returning a newly allocated <see cref="HttpClient"/> on every
|
||||
/// invocation will leak sockets and handler resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public DefaultHttpRequestHandler(Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? httpClientProvider)
|
||||
{
|
||||
this._httpClientProvider = httpClientProvider;
|
||||
this._ownedHttpClient = new Lazy<HttpClient>(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
private static Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>> CreateSingleClientProvider(HttpClient httpClient)
|
||||
{
|
||||
if (httpClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
return (_, _) => Task.FromResult<HttpClient?>(httpClient);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HttpRequestResult> SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
{
|
||||
throw new ArgumentException("Request URL must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Method))
|
||||
{
|
||||
throw new ArgumentException("Request method must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
HttpClient? providedClient = null;
|
||||
if (this._httpClientProvider is not null)
|
||||
{
|
||||
providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
HttpClient client = providedClient ?? this._ownedHttpClient.Value;
|
||||
|
||||
using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request);
|
||||
|
||||
using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: null;
|
||||
|
||||
timeoutCts?.CancelAfter(request.Timeout!.Value);
|
||||
|
||||
CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken;
|
||||
|
||||
using HttpResponseMessage httpResponse = await client
|
||||
.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? body = httpResponse.Content is null
|
||||
? null
|
||||
#if NET
|
||||
: await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false);
|
||||
#else
|
||||
: await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
#endif
|
||||
|
||||
Dictionary<string, IReadOnlyList<string>> headers = new(StringComparer.OrdinalIgnoreCase);
|
||||
AppendHeaders(headers, httpResponse.Headers);
|
||||
if (httpResponse.Content is not null)
|
||||
{
|
||||
AppendHeaders(headers, httpResponse.Content.Headers);
|
||||
}
|
||||
|
||||
return new HttpRequestResult
|
||||
{
|
||||
StatusCode = (int)httpResponse.StatusCode,
|
||||
IsSuccessStatusCode = httpResponse.IsSuccessStatusCode,
|
||||
Body = body,
|
||||
Headers = headers,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._ownedHttpClient.IsValueCreated)
|
||||
{
|
||||
this._ownedHttpClient.Value.Dispose();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request)
|
||||
{
|
||||
HttpMethod method = ResolveMethod(request.Method);
|
||||
string requestUri = ResolveRequestUri(request);
|
||||
HttpRequestMessage httpRequest = new(method, requestUri);
|
||||
|
||||
if (request.Body is not null)
|
||||
{
|
||||
string contentType = string.IsNullOrWhiteSpace(request.BodyContentType)
|
||||
? "text/plain"
|
||||
: request.BodyContentType!;
|
||||
|
||||
httpRequest.Content = new StringContent(request.Body, Encoding.UTF8);
|
||||
// Replace the default content-type header (including charset) with the declared type.
|
||||
httpRequest.Content.Headers.Remove("Content-Type");
|
||||
httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
}
|
||||
|
||||
if (request.Headers is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> header in request.Headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-* headers belong on HttpContent; all others belong on the request.
|
||||
if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null)
|
||||
{
|
||||
httpRequest.Content.Headers.Remove(header.Key);
|
||||
httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
{
|
||||
httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return httpRequest;
|
||||
}
|
||||
|
||||
private static HttpMethod ResolveMethod(string method)
|
||||
{
|
||||
string normalized = method.Trim().ToUpperInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"GET" => HttpMethod.Get,
|
||||
"POST" => HttpMethod.Post,
|
||||
"PUT" => HttpMethod.Put,
|
||||
"DELETE" => HttpMethod.Delete,
|
||||
#if NET
|
||||
"PATCH" => HttpMethod.Patch,
|
||||
#else
|
||||
"PATCH" => new HttpMethod("PATCH"),
|
||||
#endif
|
||||
_ => new HttpMethod(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRequestUri(HttpRequestInfo request)
|
||||
{
|
||||
string baseUrl = request.Url;
|
||||
if (request.QueryParameters is null || request.QueryParameters.Count == 0)
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
StringBuilder queryBuilder = new();
|
||||
foreach (KeyValuePair<string, string> parameter in request.QueryParameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameter.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (queryBuilder.Length > 0)
|
||||
{
|
||||
queryBuilder.Append('&');
|
||||
}
|
||||
|
||||
queryBuilder.Append(Uri.EscapeDataString(parameter.Key))
|
||||
.Append('=')
|
||||
.Append(Uri.EscapeDataString(parameter.Value ?? string.Empty));
|
||||
}
|
||||
|
||||
if (queryBuilder.Length == 0)
|
||||
{
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
char separator = baseUrl.Contains('?') ? '&' : '?';
|
||||
return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString());
|
||||
}
|
||||
|
||||
private static void AppendHeaders(
|
||||
Dictionary<string, IReadOnlyList<string>> target,
|
||||
System.Net.Http.Headers.HttpHeaders source)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in source)
|
||||
{
|
||||
string[] values = header.Value.ToArray();
|
||||
|
||||
if (target.TryGetValue(header.Key, out IReadOnlyList<string>? existing))
|
||||
{
|
||||
List<string> combined = new(existing);
|
||||
combined.AddRange(values);
|
||||
target[header.Key] = combined;
|
||||
}
|
||||
else
|
||||
{
|
||||
target[header.Key] = values;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for executing HTTP requests emitted by <c>HttpRequestAction</c> within declarative workflows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations
|
||||
/// for local development, hosted workflows, authenticated scenarios, and testing.
|
||||
/// </remarks>
|
||||
public interface IHttpRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
/// <param name="request">The HTTP request to send.</param>
|
||||
/// <param name="cancellationToken">A token to observe cancellation.</param>
|
||||
/// <returns>The <see cref="HttpRequestResult"/> describing the HTTP response.</returns>
|
||||
Task<HttpRequestResult> SendAsync(
|
||||
HttpRequestInfo request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an HTTP request to be sent by an <see cref="IHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")]
|
||||
public sealed class HttpRequestInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE).
|
||||
/// </summary>
|
||||
public string Method { get; init; } = "GET";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute URL to send the request to.
|
||||
/// </summary>
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the headers to include on the request, excluding the <c>Content-Type</c> header (which is supplied via <see cref="BodyContentType"/>).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <c>Content-Type</c> of the request body, or <see langword="null"/> if no body is sent.
|
||||
/// </summary>
|
||||
public string? BodyContentType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serialized request body, or <see langword="null"/> if no body is sent.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum amount of time to wait for the request to complete, or <see langword="null"/> to use the handler default.
|
||||
/// </summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the query parameters to append to the request URL, with values already formatted as strings.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? QueryParameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the declared remote connection, or <see langword="null"/> if no connection is declared.
|
||||
/// This maps to the Foundry project connection Id and is only used when running in foundry service.
|
||||
/// </summary>
|
||||
public string? ConnectionName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of an HTTP request executed by an <see cref="IHttpRequestHandler"/>.
|
||||
/// </summary>
|
||||
public sealed class HttpRequestResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the HTTP status code returned by the server.
|
||||
/// </summary>
|
||||
public int StatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the status code is in the range 200-299.
|
||||
/// </summary>
|
||||
public bool IsSuccessStatusCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response body, or <see langword="null"/> if no body was returned.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response headers keyed by header name. Each header may have multiple values.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>>? Headers { get; init; }
|
||||
}
|
||||
+12
-2
@@ -529,6 +529,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
|
||||
}
|
||||
|
||||
protected override void Visit(HttpRequestAction item)
|
||||
{
|
||||
this.Trace(item);
|
||||
|
||||
if (this._workflowOptions.HttpRequestHandler is null)
|
||||
{
|
||||
throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions.");
|
||||
}
|
||||
|
||||
this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState));
|
||||
}
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
@@ -573,8 +585,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
|
||||
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(HttpRequestAction item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(RecognizeIntent item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(TransferConversation item) => this.NotSupported(item);
|
||||
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for the <see cref="HttpRequestAction"/> action.
|
||||
/// Dispatches the request through the configured <see cref="IHttpRequestHandler"/> and assigns
|
||||
/// the response body and headers to the declared property paths.
|
||||
/// </summary>
|
||||
internal sealed class HttpRequestExecutor(
|
||||
HttpRequestAction model,
|
||||
IHttpRequestHandler httpRequestHandler,
|
||||
ResponseAgentProvider agentProvider,
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<HttpRequestAction>(model, state)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string method = this.GetMethod();
|
||||
string url = this.GetUrl();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
Dictionary<string, string>? queryParameters = this.GetQueryParameters();
|
||||
(string? body, string? contentType) = this.GetBody();
|
||||
TimeSpan? timeout = this.GetTimeout();
|
||||
string? conversationId = this.GetConversationId();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
HttpRequestInfo requestInfo = new()
|
||||
{
|
||||
Method = method,
|
||||
Url = url,
|
||||
Headers = headers,
|
||||
QueryParameters = queryParameters,
|
||||
Body = body,
|
||||
BodyContentType = contentType,
|
||||
Timeout = timeout,
|
||||
ConnectionName = connectionName,
|
||||
};
|
||||
|
||||
HttpRequestResult result;
|
||||
try
|
||||
{
|
||||
result = await httpRequestHandler.SendAsync(requestInfo, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw this.Exception($"HTTP request to '{url}' timed out.");
|
||||
}
|
||||
catch (Exception exception) when (exception is not DeclarativeActionException)
|
||||
{
|
||||
throw this.Exception($"HTTP request to '{url}' failed: {exception.Message}", exception);
|
||||
}
|
||||
|
||||
if (result.IsSuccessStatusCode)
|
||||
{
|
||||
await this.AssignResponseAsync(context, result.Body).ConfigureAwait(false);
|
||||
await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false);
|
||||
await this.AddResponseToConversationAsync(conversationId, result.Body, cancellationToken).ConfigureAwait(false);
|
||||
return default;
|
||||
}
|
||||
|
||||
// Non-success status code - throw.
|
||||
// Also publish response headers for diagnostic purposes.
|
||||
await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false);
|
||||
|
||||
string bodyPreview = FormatBodyForDiagnostics(result.Body);
|
||||
string message = bodyPreview.Length == 0
|
||||
? $"HTTP request to '{url}' failed with status code {result.StatusCode}."
|
||||
: $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{bodyPreview}'";
|
||||
|
||||
throw this.Exception(message);
|
||||
}
|
||||
|
||||
// Response bodies can echo secrets (tokens, PII) and may be very large (multi-MB HTML error pages).
|
||||
// Exception messages are often logged and persisted, so we clip the body to bound both exposure
|
||||
// and message size. Full bodies are still available via the success path (assigned to Response).
|
||||
private const int MaxBodyDiagnosticLength = 256;
|
||||
private const string BodyTruncationSuffix = " \u2026 [truncated]";
|
||||
|
||||
private static string FormatBodyForDiagnostics(string? body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
int sourceLen = body!.Length;
|
||||
bool truncated = sourceLen > MaxBodyDiagnosticLength;
|
||||
int copyLen = truncated ? MaxBodyDiagnosticLength : sourceLen;
|
||||
int finalLen = copyLen + (truncated ? BodyTruncationSuffix.Length : 0);
|
||||
|
||||
// Size the buffer for the final string so we only allocate once for the chars
|
||||
// and once for the string itself. For a 10 KB error body we touch 256 chars instead of 10,000.
|
||||
char[] buffer = new char[finalLen];
|
||||
for (int i = 0; i < copyLen; i++)
|
||||
{
|
||||
char c = body[i];
|
||||
buffer[i] = c is '\r' or '\n' or '\t' ? ' ' : c;
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
BodyTruncationSuffix.CopyTo(0, buffer, copyLen, BodyTruncationSuffix.Length);
|
||||
}
|
||||
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken)
|
||||
{
|
||||
if (conversationId is null || string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChatMessage message = new(ChatRole.Assistant, responseBody);
|
||||
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignResponseAsync(IWorkflowContext context, string? responseBody)
|
||||
{
|
||||
if (this.Model.Response is not { Path: { } responsePath })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this.AssignAsync(responsePath, ParseResponseBody(responseBody), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignResponseHeadersAsync(IWorkflowContext context, IReadOnlyDictionary<string, IReadOnlyList<string>>? responseHeaders)
|
||||
{
|
||||
if (this.Model.ResponseHeaders is not { Path: { } headersPath })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseHeaders is null || responseHeaders.Count == 0)
|
||||
{
|
||||
await this.AssignAsync(headersPath, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Flatten multi-value headers by joining with commas (standard HTTP header folding).
|
||||
Dictionary<string, object?> flattened = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, IReadOnlyList<string>> header in responseHeaders)
|
||||
{
|
||||
flattened[header.Key] = string.Join(",", header.Value);
|
||||
}
|
||||
|
||||
await this.AssignAsync(headersPath, flattened.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static FormulaValue ParseResponseBody(string? responseBody)
|
||||
{
|
||||
if (string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
return FormulaValue.NewBlank();
|
||||
}
|
||||
|
||||
// Attempt to parse as JSON so records/tables are exposed naturally to the workflow.
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(responseBody);
|
||||
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
|
||||
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
||||
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l)
|
||||
? l
|
||||
: jsonDocument.RootElement.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => responseBody,
|
||||
};
|
||||
|
||||
return parsedValue.ToFormula();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON — return the raw string.
|
||||
return FormulaValue.New(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetMethod()
|
||||
{
|
||||
EnumExpression<HttpMethodTypeWrapper>? methodExpression = this.Model.Method;
|
||||
if (methodExpression is null)
|
||||
{
|
||||
return "GET";
|
||||
}
|
||||
|
||||
HttpMethodTypeWrapper wrapper = this.Evaluator.GetValue(methodExpression).Value;
|
||||
return !string.IsNullOrEmpty(wrapper.UnknownValue) ? wrapper.UnknownValue! : wrapper.Value.ToString().ToUpperInvariant();
|
||||
}
|
||||
|
||||
private string GetUrl() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.Url,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.Url)}")).Value;
|
||||
|
||||
private Dictionary<string, string>? GetHeaders()
|
||||
{
|
||||
if (this.Model.Headers is null || this.Model.Headers.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
|
||||
{
|
||||
string value = this.Evaluator.GetValue(header.Value).Value;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
result[header.Key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private (string? Body, string? ContentType) GetBody()
|
||||
{
|
||||
switch (this.Model.Body)
|
||||
{
|
||||
case null:
|
||||
case NoRequestContent:
|
||||
return (null, null);
|
||||
|
||||
case JsonRequestContent jsonContent when jsonContent.Content is not null:
|
||||
{
|
||||
FormulaValue formula = this.Evaluator.GetValue(jsonContent.Content).Value.ToFormula();
|
||||
string json = formula.ToJson().ToJsonString();
|
||||
return (json, "application/json");
|
||||
}
|
||||
|
||||
case RawRequestContent rawContent:
|
||||
{
|
||||
string? content = rawContent.Content is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(rawContent.Content).Value;
|
||||
|
||||
string? contentType = rawContent.ContentType is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(rawContent.ContentType).Value;
|
||||
|
||||
return (content, string.IsNullOrEmpty(contentType) ? null : contentType);
|
||||
}
|
||||
|
||||
default:
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan? GetTimeout()
|
||||
{
|
||||
if (this.Model.RequestTimeoutInMilliseconds is null || this.Model.RequestTimeoutInMillisecondsIsDefaultValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long value = this.Evaluator.GetValue(this.Model.RequestTimeoutInMilliseconds).Value;
|
||||
return value > 0 ? TimeSpan.FromMilliseconds(value) : null;
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? GetQueryParameters()
|
||||
{
|
||||
if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = new(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, ValueExpression> parameter in this.Model.QueryParameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameter.Key) || parameter.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? rawValue = this.Evaluator.GetValue(parameter.Value).Value.ToObject();
|
||||
string? formatted = FormatQueryValue(rawValue);
|
||||
if (formatted is not null)
|
||||
{
|
||||
result[parameter.Key] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
private static string? FormatQueryValue(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
bool b => b ? "true" : "false",
|
||||
IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
|
||||
private string? GetConversationId()
|
||||
{
|
||||
if (this.Model.ConversationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
{
|
||||
RemoteConnection? connection = this.Model.Connection;
|
||||
if (connection is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? name = connection.Name is null
|
||||
? null
|
||||
: this.Evaluator.GetValue(connection.Name).Value;
|
||||
|
||||
return string.IsNullOrEmpty(name) ? null : name;
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for the first input before starting
|
||||
// The consumer will call EnqueueMessageAsync which signals the run loop
|
||||
// Wait for the first input before starting.
|
||||
// The consumer will call EnqueueMessageAsync which signals the run loop.
|
||||
// Note: AsyncRunHandle also signals here on checkpoint resume when there are
|
||||
// already pending requests, so the first iteration can emit a PendingRequests
|
||||
// halt signal even without unprocessed messages.
|
||||
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
this._runStatus = RunStatus.Running;
|
||||
|
||||
while (!linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
// Start a new run-stage activity for this input→processing→halt cycle
|
||||
@@ -95,6 +96,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Events are streamed out in real-time as they happen via the event handler
|
||||
if (this._stepRunner.HasUnprocessedMessages)
|
||||
{
|
||||
// Flip to Running only when there's actual work to process.
|
||||
// This is intentionally inside the HasUnprocessedMessages branch so
|
||||
// that stale input signals cannot transiently flip status back to
|
||||
// Running after a prior halt has already been observed by callers
|
||||
// (e.g. Run.ResumeAsync returning after reading an Idle halt signal).
|
||||
this._runStatus = RunStatus.Running;
|
||||
|
||||
// Emit WorkflowStartedEvent only when there's actual work to process
|
||||
// This avoids spurious events on timeout-only loop iterations
|
||||
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false);
|
||||
@@ -129,9 +137,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Wait for next input from the consumer
|
||||
// Works for both Idle (no work) and PendingRequests (waiting for responses)
|
||||
await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// When signaled, resume running
|
||||
this._runStatus = RunStatus.Running;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
|
||||
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -29,6 +43,13 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -43,6 +64,20 @@
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -71,6 +106,13 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -85,6 +127,20 @@
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -113,6 +169,13 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -127,6 +190,20 @@
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -155,6 +232,13 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -169,6 +253,20 @@
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
|
||||
@@ -197,6 +295,13 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0002</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
|
||||
@@ -211,4 +316,39 @@
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
<Suppression>
|
||||
<DiagnosticId>CP0005</DiagnosticId>
|
||||
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
|
||||
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
|
||||
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
|
||||
<IsBaselineSuppression>true</IsBaselineSuppression>
|
||||
</Suppression>
|
||||
</Suppressions>
|
||||
@@ -35,7 +35,8 @@ public abstract class AgentSkill
|
||||
/// Gets the full skill content.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For file-based skills this is the raw SKILL.md file content.
|
||||
/// For file-based skills this is the raw SKILL.md file content, optionally
|
||||
/// augmented with a synthesized scripts block when scripts are present.
|
||||
/// For code-defined skills this is a synthesized XML document
|
||||
/// containing name, description, and body (instructions, resources, scripts).
|
||||
/// </remarks>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -46,8 +46,9 @@ public abstract class AgentSkillScript
|
||||
/// Runs the script with the given arguments.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill that owns this script.</param>
|
||||
/// <param name="arguments">Arguments for script execution.</param>
|
||||
/// <param name="arguments">Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller.</param>
|
||||
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
|
||||
public abstract Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -243,7 +244,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
|
||||
AIFunction scriptFunction = AIFunctionFactory.Create(
|
||||
(string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
(string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) =>
|
||||
this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken),
|
||||
name: "run_skill_script",
|
||||
description: "Runs a script associated with a skill.");
|
||||
@@ -340,7 +341,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(skillName))
|
||||
{
|
||||
@@ -366,7 +367,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
|
||||
|
||||
try
|
||||
{
|
||||
return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
|
||||
return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
{
|
||||
private readonly IReadOnlyList<AgentSkillResource> _resources;
|
||||
private readonly IReadOnlyList<AgentSkillScript> _scripts;
|
||||
private readonly string _originalContent;
|
||||
private string? _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentFileSkill"/> class.
|
||||
@@ -32,7 +34,7 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
IReadOnlyList<AgentSkillScript>? scripts = null)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this.Content = Throw.IfNull(content);
|
||||
this._originalContent = Throw.IfNull(content);
|
||||
this.Path = Throw.IfNullOrWhitespace(path);
|
||||
this._resources = resources ?? [];
|
||||
this._scripts = scripts ?? [];
|
||||
@@ -42,7 +44,18 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Content { get; }
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content. When the skill has scripts, a
|
||||
/// <c><scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts></c>
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override string Content
|
||||
{
|
||||
get => this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory path where the skill was discovered.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -16,6 +16,11 @@ namespace Microsoft.Agents.AI;
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
{
|
||||
/// <summary>
|
||||
/// Cached JSON schema element describing the expected argument format: a string array of CLI arguments.
|
||||
/// </summary>
|
||||
private static readonly JsonElement s_defaultSchema = CreateDefaultSchema();
|
||||
|
||||
private readonly AgentFileSkillScriptRunner? _runner;
|
||||
|
||||
/// <summary>
|
||||
@@ -37,7 +42,14 @@ public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
public string FullPath { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
/// <remarks>
|
||||
/// Returns a fixed schema describing a string array of CLI arguments:
|
||||
/// <c>{"type":"array","items":{"type":"string"}}</c>.
|
||||
/// </remarks>
|
||||
public override JsonElement? ParametersSchema => s_defaultSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (skill is not AgentFileSkill fileSkill)
|
||||
{
|
||||
@@ -51,6 +63,12 @@ public sealed class AgentFileSkillScript : AgentSkillScript
|
||||
$"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
|
||||
}
|
||||
|
||||
return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false);
|
||||
return await this._runner(fileSkill, this, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static JsonElement CreateDefaultSchema()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse("""{"type":"array","items":{"type":"string"}}""");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -13,15 +14,19 @@ namespace Microsoft.Agents.AI;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
|
||||
/// The <paramref name="arguments"/> parameter preserves the raw JSON sent by the caller, in the shape
|
||||
/// described by <see cref="AgentFileSkillScript.ParametersSchema"/>.
|
||||
/// </remarks>
|
||||
/// <param name="skill">The skill that owns the script.</param>
|
||||
/// <param name="script">The file-based script to run.</param>
|
||||
/// <param name="arguments">Optional arguments for the script, provided by the agent/LLM.</param>
|
||||
/// <param name="arguments">Raw JSON arguments for the script, in the shape described by <see cref="AgentFileSkillScript.ParametersSchema"/>.</param>
|
||||
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The script execution result.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public delegate Task<object?> AgentFileSkillScriptRunner(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
AIFunctionArguments arguments,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
+49
-25
@@ -59,36 +59,60 @@ internal static class AgentInlineSkillContentBuilder
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<scripts>\n");
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptsBlock(scripts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c><scripts>...</scripts></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><script name="..."></c> element with optional
|
||||
/// <c>description</c> attribute and <c><parameters_schema></c> child element.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <returns>An XML string starting with <c>\n<scripts></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<scripts>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes XML special characters: always escapes <c>&</c>, <c><</c>, <c>></c>,
|
||||
/// <c>"</c>, and <c>'</c>. When <paramref name="preserveQuotes"/> is <see langword="true"/>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
@@ -67,8 +68,42 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript
|
||||
public override JsonElement? ParametersSchema => this._function.JsonSchema;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
|
||||
public override async Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
|
||||
var funcArgs = ConvertToFunctionArguments(arguments);
|
||||
funcArgs.Services = serviceProvider;
|
||||
|
||||
return await this._function.InvokeAsync(funcArgs, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw <see cref="JsonElement"/> to <see cref="AIFunctionArguments"/> for delegate invocation.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <paramref name="arguments"/> is provided but is not a JSON object.
|
||||
/// Inline skill scripts expect arguments as a JSON object whose properties map to the delegate's parameters.
|
||||
/// </exception>
|
||||
private static AIFunctionArguments ConvertToFunctionArguments(JsonElement? arguments)
|
||||
{
|
||||
if (arguments is null ||
|
||||
arguments.Value.ValueKind == JsonValueKind.Null ||
|
||||
arguments.Value.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (arguments.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'.");
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var property in arguments.Value.EnumerateObject())
|
||||
{
|
||||
dict[property.Name] = property.Value;
|
||||
}
|
||||
|
||||
return new AIFunctionArguments(dict);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,18 @@ public sealed class A2AContinuationTokenTests
|
||||
Assert.Throws<ArgumentException>(() => A2AContinuationToken.FromToken(emptyToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithNullTaskIdValue_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory();
|
||||
var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId);
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<JsonException>(() => A2AContinuationToken.FromToken(mockToken));
|
||||
Assert.Contains("taskId", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithMissingTaskIdProperty_ThrowsException()
|
||||
{
|
||||
|
||||
+12
-12
@@ -42,14 +42,14 @@ public sealed class A2AAIContentExtensionsTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
|
||||
Assert.Equal("First text", result[0].Text);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result[1]);
|
||||
Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
|
||||
Assert.Equal("https://example.com/file1.txt", result[1].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
|
||||
Assert.Equal("Second text", result[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,14 +72,14 @@ public sealed class A2AAIContentExtensionsTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
|
||||
Assert.Equal("First text", result[0].Text);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result[1]);
|
||||
Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
|
||||
Assert.Equal("https://example.com/file.txt", result[1].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
|
||||
Assert.Equal("Second text", result[2].Text);
|
||||
}
|
||||
|
||||
// Mock class for testing unsupported scenarios
|
||||
|
||||
+112
-8
@@ -26,7 +26,7 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
using var handler = new HttpMessageHandlerStub();
|
||||
using var httpClient = new HttpClient(handler, false);
|
||||
|
||||
handler.ResponsesToReturn.Enqueue(new AgentMessage
|
||||
handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var agent = this._agentCard.AsAIAgent(httpClient);
|
||||
var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
@@ -66,6 +66,105 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Multi-Interface Agent",
|
||||
Description = "An agent with multiple interfaces",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
|
||||
new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
|
||||
]
|
||||
};
|
||||
|
||||
using var handler = new HttpMessageHandlerStub();
|
||||
using var httpClient = new HttpClient(handler, false);
|
||||
|
||||
handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var options = new A2AClientOptions
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
};
|
||||
|
||||
var agent = card.AsAIAgent(httpClient, options: options);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Single(handler.CapturedUris);
|
||||
Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Default Options Agent",
|
||||
Description = "Tests default A2AClientOptions behavior",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://default/agent" },
|
||||
]
|
||||
};
|
||||
|
||||
// Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback)
|
||||
var agent = card.AsAIAgent(options: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("Default Options Agent", agent.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithNoMatchingBinding_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Unmatched Binding Agent",
|
||||
Description = "Agent with unsupported binding only",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" },
|
||||
]
|
||||
};
|
||||
|
||||
var options = new A2AClientOptions
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
};
|
||||
|
||||
// Act & Assert - factory should throw when no matching binding exists
|
||||
Assert.ThrowsAny<Exception>(() => card.AsAIAgent(options: options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "No Interfaces Agent",
|
||||
Description = "Agent with no supported interfaces",
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
@@ -86,13 +185,18 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is AgentMessage message)
|
||||
else if (response is Message message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
var sendMessageResponse = new SendMessageResponse { Message = message };
|
||||
var jsonRpcResponse = new JsonRpcResponse
|
||||
{
|
||||
Id = "response-id",
|
||||
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
|
||||
};
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -40,7 +40,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -58,7 +58,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = null,
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -76,7 +76,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -94,7 +94,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = null,
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -110,14 +110,14 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
// Arrange
|
||||
var artifact = new Artifact
|
||||
{
|
||||
Parts = [new TextPart { Text = "response" }],
|
||||
Parts = [Part.FromText("response")],
|
||||
};
|
||||
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [artifact],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -136,15 +136,15 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
// Arrange
|
||||
var artifact1 = new Artifact
|
||||
{
|
||||
Parts = [new TextPart { Text = "content1" }],
|
||||
Parts = [Part.FromText("content1")],
|
||||
};
|
||||
|
||||
var artifact2 = new Artifact
|
||||
{
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "content2" },
|
||||
new TextPart { Text = "content3" }
|
||||
Part.FromText("content2"),
|
||||
Part.FromText("content3")
|
||||
],
|
||||
};
|
||||
|
||||
@@ -152,7 +152,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [artifact1, artifact2],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
+6
-6
@@ -22,9 +22,9 @@ public sealed class A2AArtifactExtensionsTests
|
||||
Name = "comprehensive-artifact",
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "First part" },
|
||||
new TextPart { Text = "Second part" },
|
||||
new TextPart { Text = "Third part" }
|
||||
Part.FromText("First part"),
|
||||
Part.FromText("Second part"),
|
||||
Part.FromText("Third part")
|
||||
],
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
@@ -66,9 +66,9 @@ public sealed class A2AArtifactExtensionsTests
|
||||
Name = "test",
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "Part 1" },
|
||||
new TextPart { Text = "Part 2" },
|
||||
new TextPart { Text = "Part 3" }
|
||||
Part.FromText("Part 1"),
|
||||
Part.FromText("Part 2"),
|
||||
Part.FromText("Part 3")
|
||||
],
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
+49
-9
@@ -37,7 +37,7 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -60,15 +60,15 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Url = "http://test-endpoint/agent"
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentMessage
|
||||
this._handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
|
||||
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
@@ -78,6 +78,41 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Options Agent",
|
||||
Description = "Agent with multiple interfaces",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
|
||||
new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
|
||||
]
|
||||
});
|
||||
this._handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var options = new A2AClientOptions
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
};
|
||||
|
||||
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, this._handler.CapturedUris.Count);
|
||||
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
@@ -104,13 +139,18 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is AgentMessage message)
|
||||
else if (response is Message message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
var sendMessageResponse = new SendMessageResponse { Message = message };
|
||||
var jsonRpcResponse = new JsonRpcResponse
|
||||
{
|
||||
Id = "response-id",
|
||||
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
|
||||
};
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,4 +30,40 @@ public sealed class A2AClientExtensionsTests
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
const string TestId = "ia2a-agent-id";
|
||||
const string TestName = "IA2A Agent";
|
||||
const string TestDescription = "Agent created from IA2AClient";
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent();
|
||||
|
||||
// Assert
|
||||
var service = agent.GetService(typeof(IA2AClient));
|
||||
Assert.NotNull(service);
|
||||
Assert.Same(a2aClient, service);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-16
@@ -32,20 +32,19 @@ public sealed class ChatMessageExtensionsTests
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.User, a2aMessage.Role);
|
||||
Assert.Equal(Role.User, a2aMessage.Role);
|
||||
|
||||
Assert.NotNull(a2aMessage.Parts);
|
||||
Assert.Equal(3, a2aMessage.Parts.Count);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
|
||||
Assert.NotNull(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
|
||||
Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
|
||||
Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
|
||||
Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -71,19 +70,18 @@ public sealed class ChatMessageExtensionsTests
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.User, a2aMessage.Role);
|
||||
Assert.Equal(Role.User, a2aMessage.Role);
|
||||
|
||||
Assert.NotNull(a2aMessage.Parts);
|
||||
Assert.Equal(3, a2aMessage.Parts.Count);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
|
||||
Assert.NotNull(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
|
||||
Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
|
||||
Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
|
||||
Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -1,5 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-4
@@ -164,10 +164,8 @@ public class AgentFrameworkResponseHandlerTelemetryTests
|
||||
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
|
||||
{
|
||||
var request = agentKey is null
|
||||
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
|
||||
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference(agentKey));
|
||||
? new CreateResponse { Model = "test" }
|
||||
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
|
||||
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
|
||||
+21
-27
@@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -72,9 +72,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -109,7 +107,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -158,7 +156,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
|
||||
var request = new CreateResponse { Model = "my-agent" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -195,7 +193,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var request = new CreateResponse { Model = "" };
|
||||
var metadata = new Metadata();
|
||||
metadata.AdditionalProperties["entity_id"] = "entity-agent";
|
||||
request.Metadata = metadata;
|
||||
@@ -235,9 +233,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("nonexistent-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -272,9 +268,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("missing-agent"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -308,7 +302,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
|
||||
var request = new CreateResponse { Model = "" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -342,7 +336,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -387,7 +381,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -435,7 +429,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -478,7 +472,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -517,9 +511,11 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
instructions: "You are a helpful assistant.");
|
||||
var request = new CreateResponse
|
||||
{
|
||||
Model = "test",
|
||||
Instructions = "You are a helpful assistant.",
|
||||
};
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -557,7 +553,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -598,9 +594,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("agent-2"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -637,7 +631,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
@@ -674,7 +668,7 @@ public class AgentFrameworkResponseHandlerTests
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_1", status = "completed", role = "user",
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FoundryToolbox"/> class.
|
||||
/// </summary>
|
||||
public class FoundryToolboxTests
|
||||
{
|
||||
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
|
||||
|
||||
#region Parameter validation tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: null!,
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: name!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolsAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullToolboxVersion_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
FoundryToolbox.ToAITools(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToAITools conversion tests
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_EmptyTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "empty-toolbox",
|
||||
version: "v1",
|
||||
description: "Empty",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: Array.Empty<ProjectsAgentTool>(),
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "null-tools-toolbox",
|
||||
version: "v1",
|
||||
description: "Null tools",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: null,
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
Assert.IsAssignableFrom<AITool>(aiTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration tests with mock HTTP
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
Assert.Single(result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
|
||||
{
|
||||
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var callCount = 0;
|
||||
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
callCount++;
|
||||
var path = request.RequestUri!.PathAndQuery;
|
||||
|
||||
if (!path.Contains("/versions/"))
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: null,
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
|
||||
{
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
await Assert.ThrowsAsync<ClientResultException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"nonexistent-toolbox",
|
||||
version: "v1",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
var tools = result.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIProjectClient extension tests
|
||||
|
||||
[Fact]
|
||||
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AIProjectClientOptions();
|
||||
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
|
||||
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
|
||||
|
||||
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -146,11 +145,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_SetsTemperatureAndTopP()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1000,
|
||||
model: "gpt-4o");
|
||||
var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" };
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
@@ -211,9 +206,9 @@ public class InputConverterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
|
||||
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
|
||||
{
|
||||
var funcOutput = new FunctionToolCallOutputResource(
|
||||
var funcOutput = new OutputItemFunctionToolCallOutput(
|
||||
callId: "call_def",
|
||||
output: BinaryData.FromString("result data"));
|
||||
|
||||
@@ -229,8 +224,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
|
||||
{
|
||||
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
|
||||
id: "reason_001");
|
||||
var reasoning = new OutputItemReasoningItem("reason_001", []);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
|
||||
|
||||
@@ -661,7 +655,7 @@ public class InputConverterTests
|
||||
[Fact]
|
||||
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
|
||||
{
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
|
||||
var request = new CreateResponse { Model = "my-model" };
|
||||
|
||||
var options = InputConverter.ConvertToChatOptions(request);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public class OutputConverterTests
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+3
-5
@@ -160,9 +160,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
|
||||
model: "test",
|
||||
agentReference: new AgentReference("my-workflow"));
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -363,7 +361,7 @@ public class WorkflowIntegrationTests
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
@@ -393,7 +391,7 @@ public class WorkflowIntegrationTests
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
+11
-2
@@ -10,7 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
@@ -34,7 +34,7 @@
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
<Compile Remove="FoundryEvalsTests.cs" />
|
||||
@@ -50,6 +50,15 @@
|
||||
<None Update="TestData\OpenAIDefaultResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "tbx-123",
|
||||
"name": "research_tools",
|
||||
"default_version": "v5"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user