Compare commits

..
Author SHA1 Message Date
Tao Chen 5769c0e8ab Fix tests 2026-04-23 15:04:58 -07:00
Tao Chen bf4c0be52b comments 2026-04-23 14:56:37 -07:00
Tao Chen a3362e2896 Add more tests 2026-04-23 14:51:23 -07:00
Tao Chen 28a86d6d73 Fix other providers 2026-04-23 14:31:26 -07:00
Tao Chen 03f7dc86d3 Fix hosting user agent missing 2026-04-23 13:49:27 -07:00
69adf6d97e .NET: Fix off-thread RunStatus race where GetStatusAsync can return Running after ResumeAsync halts (#5412)
* Fix off-thread RunStatus race where GetStatusAsync can return Running after ResumeAsync halts

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Simplify test comment.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 15:27:28 +00:00
westeyandGitHub 6851a9cdc8 .NET: Add dynamic tool expansion sample (#5425)
* Add dynamic tool expansion sample

* Address PR comments

* Remove tool names from tool call response to avoid confusing LLM
2026-04-23 15:10:57 +00:00
westeyandGitHub dfca81ff21 .NET: Update Aspire package to be preview (#5444)
* Update Aspire package to be preview

* Also update readme file

* Include README.md in pack
2026-04-23 15:10:49 +00:00
Evan MattsonandGitHub fbbc2ebe86 Propagate integration-test model credentials to issue-triage repro (#5443)
Scopes the triage job to the integration GitHub Environment, adds
the azure/login OIDC step, and exposes the same OpenAI / Azure
OpenAI / Foundry / Anthropic env vars the integration test
workflow uses. This lets the triage agent write repro code that
constructs model clients from the environment without any secrets
entering the agent prompt or generated-code literals.

Azure OpenAI and Foundry continue to authenticate via AAD
(DefaultAzureCredential), so there is no API key to leak for
those providers.
2026-04-23 21:01:24 +09:00
c9e6033048 Automated issue triage workflow (#5419)
* Automated issue triage workflow

* Bump dependencies

* Fix issue-triage workflow: security, reliability, and testability

Address six review comments on the issue-triage workflow:

1. Change trigger from issues:opened to issues:labeled so the
   secret-backed triage flow is only triggered by a maintainer-
   controlled signal.

2. Include inputs.issue_number in the concurrency group so
   workflow_dispatch runs for the same issue are properly
   de-duplicated.

3. Improve team membership error handling to fail closed: verify
   the team exists before checking membership, and only treat a
   404 as 'not a member' (all other errors fail the job).

4. Use optional chaining (issue.user?.login) for the API-fetched
   issue to handle deleted GitHub accounts without crashing.

5. Extract the inline github-script into a testable module at
   .github/scripts/check_team_membership.js with 10 tests in
   .github/tests/test_check_team_membership.js covering all
   code paths (payload/API author resolution, deleted accounts,
   team lookup failure, 404 vs non-404 membership errors).

6. Make the spam gate actually stop the job by exiting non-zero
   instead of just logging, so future steps cannot accidentally
   run for spam issues.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Make issue-triage workflow manually triggered only for initial testing

Remove the 'issues' event trigger, keeping only 'workflow_dispatch' so the
workflow can be tested manually before enabling automatic triggers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 20:22:04 +09:00
9ca55dcc0c Python: (chore): update changelog (#5438)
* update changelog

* Update python/CHANGELOG.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/CHANGELOG.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 17:50:29 +09:00
58ff4ad3a9 Python: Hyperlight: thread-confine sandbox, skip parsing on host callbacks, schema/tool cleanup (#5424)
* improved parsing of tool call results and tweaks

* Address PR review: skip_parsing flag, broader registry close, comment fix

- FunctionTool.invoke now takes a boolean skip_parsing flag instead of the
  SKIP_PARSING sentinel; the sentinel is still accepted as result_parser at
  construction time to opt out of parsing for every call. The two paths are
  equivalent.
- _SandboxRegistry.close now invokes any sandbox close/shutdown hook on the
  entry's own worker thread (PyO3 unsendable), then shuts the worker down,
  then cleans up the per-entry temporary directories.
- Clarified the _SandboxWorker.shutdown comment to describe the actual
  ThreadPoolExecutor.shutdown(wait=False, cancel_futures=False) semantics.
- Hyperlight host callback uses skip_parsing=True (the new flag).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Drop redundant 'is not SKIP_PARSING' guard that mypy 1.x flags

After callable(configured_parser) the sentinel is already excluded; the extra
identity check tripped mypy's non-overlapping identity warning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fixed sandbox working on copy of tool

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:14:51 +00:00
66e02c10e3 .NET: [Breaking] Migrate A2A agent and hosting to A2A SDK v1 (#5423)
* update a2a agent to the latest a2a sdk (#5257)

* Move A2A samples from 04-hosting to 02-agents (#5267)

Move the A2A sample projects (A2AAgent_AsFunctionTools and
A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to
samples/02-agents/A2A/ to better align with the sample directory
structure. Update solution file and samples README accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Fix stream reconnection for A2AAgent (#5275)

* Add SSE stream reconnection support to A2AAgent

Implement automatic reconnection for SSE streams that disconnect mid-task,
using the Last-Event-ID header to resume from where the stream left off.

Changes:
- Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable
  max retries and delay between attempts
- Add new log messages for reconnection events
- Add A2AAgent_StreamReconnection sample demonstrating the feature
- Update existing polling sample to use simplified SendMessageAsync API
- Add unit tests for stream reconnection logic

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address comments

* Address PR review feedback

- Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection
- Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding
- Print update.Text instead of update object in stream reconnection sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Use IA2AClientFactory to create A2AClient (#5277)

* Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample

- Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions
- Update A2ACardResolverExtensions to accept IA2AClientFactory
- Update A2AClientExtensions to accept IA2AClientFactory
- Update A2AAgent to use IA2AClientFactory for client creation
- Add A2AAgent_ProtocolSelection sample demonstrating protocol selection
- Add comprehensive unit tests for all changes
- Update README files with new sample reference

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reorder params: options before loggerFactory in A2A extensions

Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent
and GetAIAgentAsync extension methods to follow the repo convention
of keeping LoggerFactory and CancellationToken as the last parameters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Migrate A2A hosting to A2A SDK v1 (#5363)

* .NET: Migrate A2A hosting to A2A SDK v1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* remove unused agent card

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* .NET: Split A2A endpoint mapping into protocol-specific methods (#5413)

* .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions

- Rename A2AHostingOptions to A2AServerRegistrationOptions
- Move server registration logic from A2AEndpointRouteBuilderExtensions
  and AIAgentExtensions into new A2AServerServiceCollectionExtensions
- Remove A2AProtocolBinding and AIAgentExtensions (consolidated)
- Update samples and tests to use the new registration API

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address copilot comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unnecessary using directive in AgentWebChat.AgentHost

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* restore AsyncEnumerable package version

* address copilot initial feedback

* address automated code review and formatting issues

* fix formatting issues

* Add DI wiring verification tests for AddA2AServer

Add three tests to A2AServerServiceCollectionExtensionsTests that verify
custom keyed services are actually wired through to the A2AServer, not
just that the server resolves non-null:

- Custom IAgentHandler: verifies the keyed handler is invoked when
  processing a SendMessageRequest instead of the default A2AAgentHandler.
- Custom AgentSessionStore (no handler): verifies the keyed session
  store's GetSessionAsync is called during request processing when no
  custom handler is registered.
- Default stores end-to-end: verifies the InMemoryAgentSessionStore and
  InMemoryTaskStore defaults successfully process a request. Uses a new
  CreateAgentMockForRequests helper that includes SerializeSessionCoreAsync
  setup needed by InMemoryAgentSessionStore.

All tests call A2AServer.SendMessageAsync directly (no HTTP layer needed)
and use CancellationToken timeouts to guard against hangs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 07:53:00 +00:00
Evan MattsonandGitHub acec9caa2f Python: Bump Python package versions for a release. (#5432)
* Bump Python version for a release.

* Revert lockstep bumps on unchanged connectors

Per PR review: only connectors that changed (or whose published metadata
changed) should get new versions. Keeps released tier at 1.1.1, a2a/ag-ui
at 1.0.0b260422, foundry-hosting at 1.0.0a260422; reverts the 19 unchanged
betas and 2 unchanged alphas to 1.0.0b260421/1.0.0a260421. Reverts all 26
non-core agent-framework-core floors to >=1.1.0,<2 since no connector
actually depends on a 1.1.1 API or bug fix.

* Restore lockstep prerelease bumps and raise core floors to >=1.1.1

Reverses the lean-revert: all beta packages stamped 1.0.0b260423 and alpha
packages stamped 1.0.0a260423 (Asia date, matching release cut time). All
26 non-core packages raise agent-framework-core lower bound from >=1.1.0,<2
to >=1.1.1,<2 to signal the validated cohort for this release. CHANGELOG
date updated to 2026-04-23.
2026-04-23 16:40:14 +09:00
Evan MattsonandGitHub 5d4873888f Don't fail if review issue occurs (#5434) 2026-04-23 13:24:21 +09:00
Evan MattsonandGitHub e2f161c8a0 Pin to specific release (#5430) 2026-04-23 08:23:56 +09:00
129 changed files with 6011 additions and 2918 deletions
+61
View File
@@ -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;
+178
View File
@@ -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')));
});
});
+4
View File
@@ -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.
+199
View File
@@ -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"
@@ -424,7 +424,7 @@ jobs:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
@@ -441,7 +441,7 @@ jobs:
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v5
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
+2 -2
View File
@@ -588,7 +588,7 @@ jobs:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
@@ -605,7 +605,7 @@ jobs:
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v5
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
@@ -701,7 +701,7 @@ jobs:
- name: Restore validation history
id: cache-restore
uses: actions/cache/restore@v5
uses: actions/cache/restore@v4
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
@@ -719,7 +719,7 @@ jobs:
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Save validation history
uses: actions/cache/save@v5
uses: actions/cache/save@v4
with:
path: validation-history/
key: validation-history-${{ github.run_id }}
+5 -5
View File
@@ -42,15 +42,15 @@
<!-- 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" />
@@ -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 -->
+9 -8
View File
@@ -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" />
@@ -344,11 +342,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 +531,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" />
@@ -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>
@@ -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)
@@ -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
```
@@ -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
@@ -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
+1
View File
@@ -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 |
@@ -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)
+1 -1
View File
@@ -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
@@ -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>
+182 -99
View File
@@ -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,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);
}
}
@@ -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);
}
}
@@ -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,192 @@
// 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)
{
if (context.IsContinuation)
{
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
}
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() };
var response = await this._hostAgent.RunAsync(
chatMessages,
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).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 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;
}
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).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 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;
@@ -31,21 +31,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;
@@ -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)
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()
{
@@ -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
@@ -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")
};
}
@@ -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
@@ -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
};
@@ -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);
}
}
@@ -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);
}
}
@@ -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>
@@ -0,0 +1,966 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AAgentHandler"/> class.
/// </summary>
public sealed class A2AAgentHandlerTests
{
/// <summary>
/// Verifies that when metadata is null, the options passed to RunAsync have
/// AllowBackgroundResponses disabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
/// Verifies that when metadata is non-empty, the options passed to RunAsync have
/// AdditionalProperties populated with the converted metadata values.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenMetadataIsNonEmpty_PassesOptionsWithAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
Metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonSerializer.SerializeToElement("value1"),
["key2"] = JsonSerializer.SerializeToElement(42)
}
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
}
/// <summary>
/// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync()
{
// Arrange
AdditionalPropertiesDictionary additionalProps = new()
{
["responseKey1"] = "responseValue1",
["responseKey2"] = 123
};
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = additionalProps
};
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.NotNull(message.Metadata);
Assert.Equal(2, message.Metadata.Count);
Assert.True(message.Metadata.ContainsKey("responseKey1"));
Assert.True(message.Metadata.ContainsKey("responseKey2"));
}
/// <summary>
/// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = null
};
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Null(message.Metadata);
}
/// <summary>
/// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = []
};
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Null(message.Metadata);
}
/// <summary>
/// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false.
/// </summary>
[Fact]
public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
runMode: AgentRunMode.DisallowBackground);
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true.
/// </summary>
[Fact]
public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false.
/// </summary>
[Fact]
public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true.
/// </summary>
[Fact]
public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)));
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, task status events are emitted.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "task-1",
ContextId = "ctx-1",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert - should have emitted status update events (Submitted + Working)
Assert.True(events.StatusUpdates.Count >= 1);
Assert.Empty(events.Messages);
}
/// <summary>
/// Verifies that when the incoming message has a ContextId, it is used for the response
/// rather than generating a new one.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "",
ContextId = "my-context-123",
Message = new Message
{
MessageId = "test-id",
ContextId = "my-context-123",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Equal("my-context-123", message.ContextId);
}
/// <summary>
/// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = false,
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
TaskId = "task-1",
ContextId = "ctx-1",
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
});
// Assert - should have artifact + completed status
Assert.True(events.ArtifactUpdates.Count > 0);
Assert.True(events.StatusUpdates.Count > 0);
Assert.Empty(events.Messages);
}
/// <summary>
/// Verifies that when the agent throws during a continuation,
/// the handler emits a Failed status and re-throws the exception.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
throw new InvalidOperationException("Agent failed"));
A2AAgentHandler handler = CreateHandler(agentMock);
// Act & Assert
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.ExecuteAsync(
new RequestContext
{
StreamingResponse = false,
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
TaskId = "task-1",
ContextId = "ctx-1",
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
},
eventQueue,
CancellationToken.None));
eventQueue.Complete(null);
await readerTask;
// Assert - should have emitted Failed status
Assert.True(events.StatusUpdates.Count > 0);
}
/// <summary>
/// Verifies that when the agent throws during a continuation and the cancellation token
/// is already cancelled, the handler still emits a Failed status and re-throws the
/// original exception (not an OperationCanceledException from FailAsync).
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_WhenAgentThrowsWithCancelledToken_StillEmitsFailedStatusAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
throw new InvalidOperationException("Agent failed"));
A2AAgentHandler handler = CreateHandler(agentMock);
using var cts = new CancellationTokenSource();
cts.Cancel(); // Pre-cancel the token
// Act & Assert - the original InvalidOperationException should be thrown, not OperationCanceledException
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.ExecuteAsync(
new RequestContext
{
StreamingResponse = false,
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
TaskId = "task-1",
ContextId = "ctx-1",
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
},
eventQueue,
cts.Token));
eventQueue.Complete(null);
await readerTask;
// Assert - should have emitted Failed status even with a cancelled token
Assert.True(events.StatusUpdates.Count > 0);
}
/// <summary>
/// Verifies that when the agent throws OperationCanceledException during a continuation,
/// no Failed status is emitted.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
throw new OperationCanceledException("Cancelled"));
A2AAgentHandler handler = CreateHandler(agentMock);
// Act & Assert
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
await Assert.ThrowsAsync<OperationCanceledException>(() =>
handler.ExecuteAsync(
new RequestContext
{
StreamingResponse = false,
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
TaskId = "task-1",
ContextId = "ctx-1",
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
},
eventQueue,
CancellationToken.None));
eventQueue.Complete(null);
await readerTask;
// Assert - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed)
Assert.Empty(events.StatusUpdates);
}
/// <summary>
/// Verifies that ReferenceTaskIds throws NotSupportedException.
/// </summary>
[Fact]
public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
{
// Arrange
A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
// Act & Assert
await Assert.ThrowsAsync<NotSupportedException>(() =>
InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }],
ReferenceTaskIds = ["other-task-id"]
}
}));
}
/// <summary>
/// Verifies that when ContextId is null, a new one is generated and used in the response.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "",
ContextId = null!,
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.NotNull(message.ContextId);
Assert.NotEmpty(message.ContextId);
}
/// <summary>
/// Verifies that when Message is null, the handler still succeeds with empty chat messages.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "",
ContextId = "ctx",
Message = null!
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Equal("ctx", message.ContextId);
}
/// <summary>
/// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext.
/// </summary>
[Fact]
public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
{
// Arrange
A2ARunDecisionContext? capturedContext = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => { }),
runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) =>
{
capturedContext = ctx;
return ValueTask.FromResult(false);
}));
var requestContext = new RequestContext
{
TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
};
// Act
await InvokeExecuteAsync(handler, requestContext);
// Assert
Assert.NotNull(capturedContext);
Assert.Same(requestContext, capturedContext.RequestContext);
}
/// <summary>
/// Verifies that CancelAsync emits a Canceled status event.
/// </summary>
[Fact]
public async Task CancelAsync_EmitsCanceledStatusAsync()
{
// Arrange
A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
// Act
await handler.CancelAsync(
new RequestContext
{
StreamingResponse = false,
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
TaskId = "task-1",
ContextId = "ctx-1",
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1" }
},
eventQueue,
CancellationToken.None);
// Assert
eventQueue.Complete(null);
await readerTask;
Assert.True(events.StatusUpdates.Count > 0);
}
#pragma warning restore MEAI001
/// <summary>
/// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
/// and can execute successfully.
/// </summary>
[Fact]
public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: null);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "",
ContextId = "ctx-1",
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Equal("Reply", message.Parts![0].Text);
}
/// <summary>
/// Verifies that when a custom session store is provided, it is used instead of the
/// default InMemoryAgentSessionStore.
/// </summary>
[Fact]
public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync()
{
// Arrange
var mockSessionStore = new Mock<AgentSessionStore>();
mockSessionStore
.Setup(x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new TestAgentSession());
mockSessionStore
.Setup(x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "",
ContextId = "ctx-1",
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
});
// Assert - verify the custom session store was called
mockSessionStore.Verify(
x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-1"),
It.IsAny<CancellationToken>()),
Times.Once);
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-1"),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that when no session store is provided, the default InMemoryAgentSessionStore
/// persists sessions across multiple calls with the same context ID.
/// </summary>
[Fact]
public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync()
{
// Arrange - track how many times CreateSessionCoreAsync is called
int createSessionCallCount = 0;
var sessionInstance = new TestAgentSession();
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.Callback(() => Interlocked.Increment(ref createSessionCallCount))
.ReturnsAsync(() => new TestAgentSession());
agentMock
.Protected()
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync",
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(JsonDocument.Parse("{}").RootElement);
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync",
ItExpr.IsAny<JsonElement>(),
ItExpr.IsAny<JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(sessionInstance);
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")]));
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null);
var context = new RequestContext
{
StreamingResponse = false,
TaskId = "",
ContextId = "ctx-persistent",
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
};
// Act - call twice with the same context ID
await InvokeExecuteAsync(handler, context);
await InvokeExecuteAsync(handler, context);
// Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store)
Assert.Equal(1, createSessionCallCount);
}
/// <summary>
/// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
/// and the agent is not invoked.
/// </summary>
[Fact]
public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync()
{
// Arrange
bool agentInvoked = false;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => agentInvoked = true),
runMode: AgentRunMode.AllowBackgroundWhen((_, _) =>
throw new InvalidOperationException("Callback failed")));
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
InvokeExecuteAsync(handler, new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
}));
Assert.False(agentInvoked);
}
/// <summary>
/// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
/// </summary>
[Fact]
public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync()
{
// Arrange
CancellationToken capturedToken = default;
using var cts = new CancellationTokenSource();
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => { }),
runMode: AgentRunMode.AllowBackgroundWhen((_, ct) =>
{
capturedToken = ct;
return ValueTask.FromResult(false);
}));
// Act
var eventQueue = new AgentEventQueue();
await handler.ExecuteAsync(
new RequestContext
{
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
},
eventQueue,
cts.Token);
eventQueue.Complete(null);
// Assert
Assert.Equal(cts.Token, capturedToken);
}
/// <summary>
/// Verifies that the agent run mode is applied on the continuation/task-update path,
/// not just the new message path.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
StreamingResponse = false,
TaskId = "task-1",
ContextId = "ctx-1",
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
});
// Assert
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
private static A2AAgentHandler CreateHandler(
Mock<AIAgent> agentMock,
AgentRunMode? runMode = null,
AgentSessionStore? agentSessionStore = null)
{
runMode ??= AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agentMock.Object,
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
return new A2AAgentHandler(hostAgent, runMode);
}
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
(_, _, options, _) => optionsCallback(options))
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
return agentMock;
}
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentResponse response)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
return agentMock;
}
private static Mock<AIAgent> CreateAgentMockWithCallCount(
ref int callCount,
Func<int, AgentResponse> responseFactory)
{
StrongBox<int> callCountBox = new(callCount);
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
int currentCall = Interlocked.Increment(ref callCountBox.Value);
return responseFactory(currentCall);
});
return agentMock;
}
private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
{
var eventQueue = new AgentEventQueue();
await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
eventQueue.Complete(null);
}
private static async Task<EventCollector> CollectEventsAsync(A2AAgentHandler handler, RequestContext context)
{
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
eventQueue.Complete(null);
await readerTask;
return events;
}
private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector)
{
await foreach (var response in eventQueue)
{
switch (response.PayloadCase)
{
case StreamResponseCase.Message:
collector.Messages.Add(response.Message!);
break;
case StreamResponseCase.Task:
collector.Tasks.Add(response.Task!);
break;
case StreamResponseCase.StatusUpdate:
collector.StatusUpdates.Add(response.StatusUpdate!);
break;
case StreamResponseCase.ArtifactUpdate:
collector.ArtifactUpdates.Add(response.ArtifactUpdate!);
break;
}
}
}
#pragma warning disable MEAI001
private static ResponseContinuationToken CreateTestContinuationToken()
{
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
}
#pragma warning restore MEAI001
private sealed class EventCollector
{
public List<Message> Messages { get; } = [];
public List<AgentTask> Tasks { get; } = [];
public List<TaskStatusUpdateEvent> StatusUpdates { get; } = [];
public List<TaskArtifactUpdateEvent> ArtifactUpdates { get; } = [];
}
private sealed class TestAgentSession : AgentSession;
}
@@ -0,0 +1,559 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods.
/// </summary>
public sealed class A2AEndpointRouteBuilderExtensionsTests
{
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2AHttpJson(agentBuilder, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agentBuilder, "/a2a"));
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentBuilder_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AHttpJson with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddA2AServer("agent");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AHttpJson("agent", "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAgentBuilder_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AJsonRpc(agentBuilder, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddA2AServer("agent");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AJsonRpc("agent", "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent.
/// </summary>
[Fact]
public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var httpResult = app.MapA2AHttpJson(agentBuilder, "/a2a");
var rpcResult = app.MapA2AJsonRpc(agentBuilder, "/a2a");
Assert.NotNull(httpResult);
Assert.NotNull(rpcResult);
}
/// <summary>
/// Verifies that multiple agents can be mapped to different paths.
/// </summary>
[Fact]
public void MapA2AHttpJson_MultipleAgents_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
agent1Builder.AddA2AServer();
agent2Builder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2AHttpJson(agent1Builder, "/a2a/agent1");
app.MapA2AHttpJson(agent2Builder, "/a2a/agent2");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that custom paths can be specified for A2A endpoints.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2AHttpJson(agentBuilder, "/custom/a2a/path");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds.
/// </summary>
[Fact]
public void AddA2AServer_WithCustomOptions_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2AHttpJson("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2AJsonRpc("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson((string)null!, "/a2a"));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapA2AHttpJson(string.Empty, "/a2a"));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null path.
/// </summary>
[Fact]
public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agentBuilder, null!));
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path.
/// </summary>
[Fact]
public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
agentBuilder.AddA2AServer();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
Assert.Throws<ArgumentException>(() =>
app.MapA2AHttpJson(agentBuilder, " "));
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentNullException for null services.
/// </summary>
[Fact]
public void AddA2AServer_NullServices_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer("agent"));
Assert.Equal("services", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentNullException for null agentName.
/// </summary>
[Fact]
public void AddA2AServer_NullAgentName_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = new ServiceCollection();
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer((string)null!));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentException for empty agentName.
/// </summary>
[Fact]
public void AddA2AServer_EmptyAgentName_ThrowsArgumentException()
{
// Arrange
IServiceCollection services = new ServiceCollection();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
services.AddA2AServer(string.Empty));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder.
/// </summary>
[Fact]
public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
agentBuilder.AddA2AServer());
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null AIAgent.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAIAgent_NullAgent_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agent, "/a2a"));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentNullException for AIAgent with null Name.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAIAgent_NullName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns((string?)null);
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AHttpJson(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws ArgumentException for AIAgent with whitespace Name.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithAIAgent_WhitespaceName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns(" ");
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapA2AHttpJson(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null AIAgent.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAIAgent_NullAgent_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AJsonRpc(agent, "/a2a"));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for AIAgent with null Name.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAIAgent_NullName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns((string?)null);
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws ArgumentException for AIAgent with whitespace Name.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithAIAgent_WhitespaceName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns(" ");
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been
/// registered for the specified agent via AddA2AServer.
/// </summary>
[Fact]
public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
app.MapA2AHttpJson("agent", "/a2a"));
Assert.Contains("agent", exception.Message);
Assert.Contains("AddA2AServer", exception.Message);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been
/// registered for the specified agent via AddA2AServer.
/// </summary>
[Fact]
public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
app.MapA2AJsonRpc("agent", "/a2a"));
Assert.Contains("agent", exception.Message);
Assert.Contains("AddA2AServer", exception.Message);
}
}
@@ -1,89 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class A2AIntegrationTests
{
/// <summary>
/// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
/// </summary>
[Fact]
public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication",
Version = "1.0"
};
// Map A2A with the agent card
app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
await app.StartAsync();
try
{
// Get the test server client
TestServer testServer = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
var httpClient = testServer.CreateClient();
// Act - Query the agent card endpoint
var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
var response = await httpClient.GetAsync(requestUri);
// Assert
Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
var content = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// Verify the card has expected properties
Assert.True(root.TryGetProperty("name", out var nameProperty));
Assert.Equal("Test Agent", nameProperty.GetString());
Assert.True(root.TryGetProperty("description", out var descProperty));
Assert.Equal("A test agent for A2A communication", descProperty.GetString());
// Verify the card has a URL property and it's not null/empty
Assert.True(root.TryGetProperty("url", out var urlProperty));
Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
var url = urlProperty.GetString();
Assert.NotNull(url);
Assert.NotEmpty(url);
Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
// agentCard's URL matches the agent endpoint
Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url);
}
finally
{
await app.StopAsync();
}
}
}
@@ -0,0 +1,459 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AServerServiceCollectionExtensions"/> class.
/// </summary>
public sealed class A2AServerServiceCollectionExtensionsTests
{
/// <summary>
/// Verifies that AddA2AServer with an agent name registers a keyed A2AServer
/// that can be resolved from the service provider.
/// </summary>
[Fact]
public async Task AddA2AServer_WithAgentName_ResolvesKeyedA2AServerAsync()
{
// Arrange
const string AgentName = "test-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance registers a keyed A2AServer
/// that can be resolved from the service provider using the agent's name.
/// </summary>
[Fact]
public async Task AddA2AServer_WithAgentInstance_ResolvesKeyedA2AServerAsync()
{
// Arrange
const string AgentName = "instance-agent";
var agentMock = CreateAgentMock(AgentName);
var services = new ServiceCollection();
// Act
services.AddA2AServer(agentMock.Object);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when no ITaskStore or AgentSessionStore are registered,
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
{
// Arrange
const string AgentName = "default-stores-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
// Act
services.AddA2AServer(AgentName);
// Assert - resolution succeeds without any stores registered
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when a custom ITaskStore is registered, AddA2AServer uses it
/// instead of the default InMemoryTaskStore.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomTaskStore_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "custom-taskstore-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockTaskStore = new Mock<ITaskStore>();
services.AddKeyedSingleton(AgentName, mockTaskStore.Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it
/// instead of the default InMemoryAgentSessionStore.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "custom-sessionstore-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockSessionStore = new Mock<AgentSessionStore>();
services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that when a custom IAgentHandler is registered, AddA2AServer uses it
/// instead of creating a default A2AAgentHandler.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomAgentHandler_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "custom-handler-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockHandler = new Mock<IAgentHandler>();
services.AddKeyedSingleton(AgentName, mockHandler.Object);
// Act
services.AddA2AServer(AgentName);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that the configureOptions callback is invoked when provided.
/// </summary>
[Fact]
public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync()
{
// Arrange
const string AgentName = "options-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
bool callbackInvoked = false;
// Act
services.AddA2AServer(AgentName, options =>
{
callbackInvoked = true;
options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported;
});
// Assert - callback is invoked during resolution
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
Assert.True(callbackInvoked);
}
/// <summary>
/// Verifies that AddA2AServer with a null configureOptions does not throw.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNullConfigureOptions_ResolvesSuccessfullyAsync()
{
// Arrange
const string AgentName = "null-options-agent";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
// Act
services.AddA2AServer(AgentName, configureOptions: null);
// Assert
await using var provider = services.BuildServiceProvider();
var server = provider.GetKeyedService<A2AServer>(AgentName);
Assert.NotNull(server);
}
/// <summary>
/// Verifies that AddA2AServer throws when the agent name is null.
/// </summary>
[Fact]
public void AddA2AServer_WithNullAgentName_ThrowsArgumentException()
{
// Arrange
var services = new ServiceCollection();
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() => services.AddA2AServer(agentName: null!));
}
/// <summary>
/// Verifies that AddA2AServer throws when the agent name is whitespace.
/// </summary>
[Fact]
public void AddA2AServer_WithWhitespaceAgentName_ThrowsArgumentException()
{
// Arrange
var services = new ServiceCollection();
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() => services.AddA2AServer(agentName: " "));
}
/// <summary>
/// Verifies that AddA2AServer throws when the services parameter is null.
/// </summary>
[Fact]
public void AddA2AServer_WithNullServices_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => services.AddA2AServer("agent"));
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance throws when the agent is null.
/// </summary>
[Fact]
public void AddA2AServer_WithNullAgent_ThrowsArgumentNullException()
{
// Arrange
var services = new ServiceCollection();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => services.AddA2AServer(agent: null!));
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance throws when the agent's Name is null.
/// </summary>
[Fact]
public void AddA2AServer_WithAgent_NullName_ThrowsArgumentNullException()
{
// Arrange
var services = new ServiceCollection();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns((string?)null);
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer(agentMock.Object));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer with an agent instance throws when the agent's Name is whitespace.
/// </summary>
[Fact]
public void AddA2AServer_WithAgent_WhitespaceName_ThrowsArgumentException()
{
// Arrange
var services = new ServiceCollection();
var agentMock = new Mock<AIAgent>();
agentMock.Setup(a => a.Name).Returns(" ");
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
services.AddA2AServer(agentMock.Object));
Assert.Equal("agent.Name", exception.ParamName);
}
/// <summary>
/// Verifies that when a custom <see cref="IAgentHandler"/> is registered as a keyed service,
/// the <see cref="A2AServer"/> uses it to process requests instead of the default handler.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomHandler_CustomHandlerIsInvokedOnRequestAsync()
{
// Arrange
const string AgentName = "custom-handler-wiring";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockHandler = new Mock<IAgentHandler>();
mockHandler
.Setup(h => h.ExecuteAsync(
It.IsAny<RequestContext>(),
It.IsAny<AgentEventQueue>(),
It.IsAny<CancellationToken>()))
.Returns((RequestContext _, AgentEventQueue eq, CancellationToken ct) =>
eq.EnqueueMessageAsync(
new Message { MessageId = "resp", Role = Role.Agent, Parts = [new Part { Text = "Reply" }] }, ct).AsTask());
services.AddKeyedSingleton(AgentName, mockHandler.Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - the custom handler was invoked, not the default A2AAgentHandler
mockHandler.Verify(
h => h.ExecuteAsync(
It.IsAny<RequestContext>(),
It.IsAny<AgentEventQueue>(),
It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
}
/// <summary>
/// Verifies that when a custom <see cref="AgentSessionStore"/> is registered as a keyed service
/// and no custom <see cref="IAgentHandler"/> is registered, the default handler uses the custom
/// session store for session management during request processing.
/// </summary>
[Fact]
public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUsedOnRequestAsync()
{
// Arrange
const string AgentName = "custom-sessionstore-wiring";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
var mockSessionStore = new Mock<AgentSessionStore>();
mockSessionStore
.Setup(x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new TestAgentSession());
mockSessionStore
.Setup(x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);
services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - the custom session store was used, not InMemoryAgentSessionStore
mockSessionStore.Verify(
x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
}
/// <summary>
/// Verifies that when no custom stores or handlers are registered, the server uses
/// the default in-memory stores and processes requests successfully end-to-end.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
{
// Arrange
const string AgentName = "default-stores-request";
var services = new ServiceCollection();
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMockForRequests(AgentName).Object);
services.AddA2AServer(AgentName);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
// Act
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - request was processed successfully with default in-memory stores
Assert.NotNull(response);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
}
private static SendMessageRequest CreateTestSendMessageRequest() =>
new()
{
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }]
}
};
private static Mock<AIAgent> CreateAgentMock(string name)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns(name);
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
return agentMock;
}
/// <summary>
/// Creates a mock <see cref="AIAgent"/> with session serialization support, suitable for
/// tests that exercise the full request processing path with <see cref="InMemoryAgentSessionStore"/>.
/// </summary>
private static Mock<AIAgent> CreateAgentMockForRequests(string name)
{
Mock<AIAgent> agentMock = CreateAgentMock(name);
agentMock
.Protected()
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync",
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(JsonDocument.Parse("{}").RootElement);
return agentMock;
}
private sealed class TestAgentSession : AgentSession;
}
@@ -1,866 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentExtensions"/> class.
/// </summary>
public sealed class AIAgentExtensionsTests
{
/// <summary>
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = null
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
/// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonSerializer.SerializeToElement("value1"),
["key2"] = JsonSerializer.SerializeToElement(42)
}
});
// Assert
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
}
/// <summary>
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = []
});
// Assert
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
/// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
{
// Arrange
AdditionalPropertiesDictionary additionalProps = new()
{
["responseKey1"] = "responseValue1",
["responseKey2"] = 123
};
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = additionalProps
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(agentMessage.Metadata);
Assert.Equal(2, agentMessage.Metadata.Count);
Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
}
/// <summary>
/// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = null
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = []
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when runMode is Message, the result is always an AgentMessage even when
/// the agent would otherwise support background responses.
/// </summary>
[Fact]
public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.DisallowBackground);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
/// even when the agent completes immediately (no ContinuationToken).
/// </summary>
[Fact]
public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the returned task includes
/// intermediate messages from the initial response in its status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Status.Message);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(agentTask.Status.Message.Parts));
Assert.Equal("Starting work...", textPart.Text);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the continuation token
/// is serialized into the AgentTask.Metadata for persistence.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when a task is created (Working or Completed), the original user message
/// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally.
/// </summary>
[Fact]
public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.History);
Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the returned AgentMessage preserves the original context ID.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("ctx-123", agentMessage.ContextId);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken (long-running)
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): return completed response
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — invoke OnTaskUpdated to check on the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should now be completed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Artifact artifact = Assert.Single(updatedTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Done!", textPart.Text);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns another ContinuationToken, the task stays in Working state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): still working, return another token
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — invoke OnTaskUpdated; agent still working
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should still be in Working state
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies the full lifecycle: agent starts background work, first poll returns still working,
/// second poll returns completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: start background work
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call: still working
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Third call: done
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — first poll: still working
AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Working, currentTask.Status.State);
// Act — second poll: completed
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Completed, currentTask.Status.State);
// Assert — final output as artifact
Assert.NotNull(currentTask.Artifacts);
Artifact artifact = Assert.Single(currentTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("All done!", textPart.Text);
}
/// <summary>
/// Verifies that when the agent throws during a background operation poll,
/// the task is updated to Failed state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new InvalidOperationException("Agent failed");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws
await Assert.ThrowsAsync<InvalidOperationException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should be Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Failed, updatedTask.Status.State);
}
/// <summary>
/// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
/// </summary>
[Fact]
public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken with no progress messages,
/// the task transitions to Working state with a null status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync()
{
// Arrange
AgentResponse response = new([])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.Null(agentTask.Status.Message);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message
/// and no continuation token in metadata, the task processes history and completes with a new artifact.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: create a task with ContinuationToken
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): complete the background operation
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
// Third call (follow-up via OnTaskUpdated): complete follow-up
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create a working task (with continuation token)
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — first OnTaskUpdated: completes the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
Assert.Equal(TaskState.Completed, agentTask.Status.State);
// Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
agentTask.History ??= [];
agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
// Act — invoke OnTaskUpdated without a continuation token in metadata
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Assert.Equal(2, updatedTask.Artifacts.Count);
Artifact artifact = updatedTask.Artifacts[1];
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Follow-up done!", textPart.Text);
}
/// <summary>
/// Verifies that when a task is cancelled, the continuation token is removed from metadata.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act — create a working task with a continuation token
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
// Act — cancel the task
await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
// Assert — continuation token should be removed from metadata
Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent throws an OperationCanceledException during a poll,
/// it is re-thrown without marking the task as Failed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new OperationCanceledException("Cancelled");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws OperationCanceledException
await Assert.ThrowsAsync<OperationCanceledException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should still be Working, not Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies that when the incoming message has a ContextId, it is used for the task
/// rather than generating a new one.
/// </summary>
[Fact]
public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage
{
MessageId = "test-id",
ContextId = "my-context-123",
Role = MessageRole.User,
Parts = [new TextPart { Text = "Hello" }]
}
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("my-context-123", agentMessage.ContextId);
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
(_, _, options, _) => optionsCallback(options))
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
return agentMock;
}
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentResponse response)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
return agentMock;
}
private static async Task<A2AResponse> InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
{
Func<MessageSendParams, CancellationToken, Task<A2AResponse>>? handler = taskManager.OnMessageReceived;
Assert.NotNull(handler);
return await handler.Invoke(messageSendParams, CancellationToken.None);
}
private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
{
Func<AgentTask, CancellationToken, Task>? handler = taskManager.OnTaskUpdated;
Assert.NotNull(handler);
await handler.Invoke(agentTask, CancellationToken.None);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private static ResponseContinuationToken CreateTestContinuationToken()
{
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMockWithSequentialResponses(
AgentResponse firstResponse,
AgentResponse secondResponse,
ref int callCount)
{
return CreateAgentMockWithCallCount(ref callCount, invocation =>
invocation == 1 ? firstResponse : secondResponse);
}
private static Mock<AIAgent> CreateAgentMockWithCallCount(
ref int callCount,
Func<int, AgentResponse> responseFactory)
{
// Use a StrongBox to allow the lambda to capture a mutable reference
StrongBox<int> callCountBox = new(callCount);
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
int currentCall = Interlocked.Increment(ref callCountBox.Value);
return responseFactory(currentCall);
});
return agentMock;
}
private sealed class TestAgentSession : AgentSession;
}
@@ -0,0 +1,163 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentRunMode"/> class.
/// </summary>
public sealed class AgentRunModeTests
{
/// <summary>
/// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate.
/// </summary>
[Fact]
public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException()
{
// Arrange & Act & Assert
Assert.Throws<ArgumentNullException>(() =>
AgentRunMode.AllowBackgroundWhen(null!));
}
/// <summary>
/// Verifies that DisallowBackground equals another DisallowBackground instance.
/// </summary>
[Fact]
public void Equals_DisallowBackground_AreEqual()
{
// Arrange
var mode1 = AgentRunMode.DisallowBackground;
var mode2 = AgentRunMode.DisallowBackground;
// Act & Assert
Assert.True(mode1.Equals(mode2));
Assert.True(mode1 == mode2);
Assert.False(mode1 != mode2);
Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
}
/// <summary>
/// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance.
/// </summary>
[Fact]
public void Equals_AllowBackgroundIfSupported_AreEqual()
{
// Arrange
var mode1 = AgentRunMode.AllowBackgroundIfSupported;
var mode2 = AgentRunMode.AllowBackgroundIfSupported;
// Act & Assert
Assert.True(mode1.Equals(mode2));
Assert.True(mode1 == mode2);
}
/// <summary>
/// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal.
/// </summary>
[Fact]
public void Equals_DifferentModes_AreNotEqual()
{
// Arrange
var disallow = AgentRunMode.DisallowBackground;
var allow = AgentRunMode.AllowBackgroundIfSupported;
// Act & Assert
Assert.False(disallow.Equals(allow));
Assert.False(disallow == allow);
Assert.True(disallow != allow);
}
/// <summary>
/// Verifies that Equals returns false for null.
/// </summary>
[Fact]
public void Equals_Null_ReturnsFalse()
{
// Arrange
var mode = AgentRunMode.DisallowBackground;
// Act & Assert
Assert.False(mode.Equals(null));
Assert.False(mode.Equals((object?)null));
Assert.False(mode == null);
Assert.True(mode != null);
}
/// <summary>
/// Verifies that two null AgentRunMode values are equal.
/// </summary>
[Fact]
public void Equals_BothNull_AreEqual()
{
// Arrange
AgentRunMode? mode1 = null;
AgentRunMode? mode2 = null;
// Act & Assert
Assert.True(mode1 == mode2);
Assert.False(mode1 != mode2);
}
/// <summary>
/// Verifies that ToString returns expected values.
/// </summary>
[Fact]
public void ToString_ReturnsExpectedValues()
{
// Act & Assert
Assert.Equal("message", AgentRunMode.DisallowBackground.ToString());
Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString());
Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString());
}
/// <summary>
/// Verifies that Equals works correctly with object parameter.
/// </summary>
[Fact]
public void Equals_WithObjectParameter_WorksCorrectly()
{
// Arrange
var mode = AgentRunMode.DisallowBackground;
// Act & Assert
Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground));
Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported));
Assert.False(mode.Equals("not a run mode"));
}
/// <summary>
/// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal,
/// because equality includes delegate identity for dynamic modes.
/// </summary>
[Fact]
public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual()
{
// Arrange
var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true));
var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false));
// Act & Assert
Assert.False(mode1.Equals(mode2));
Assert.True(mode1 != mode2);
}
/// <summary>
/// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal.
/// </summary>
[Fact]
public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual()
{
// Arrange
static ValueTask<bool> CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true);
var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
// Act & Assert
Assert.True(mode1.Equals(mode2));
Assert.True(mode1 == mode2);
Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
@@ -10,66 +11,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
public class MessageConverterTests
{
[Fact]
public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection()
public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection()
{
MessageSendParams? messageSendParams = null;
SendMessageRequest? sendMessageRequest = null;
var result = messageSendParams!.ToChatMessages();
var result = sendMessageRequest!.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection()
public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection()
{
var messageSendParams = new MessageSendParams
var sendMessageRequest = new SendMessageRequest
{
Message = null!
};
var result = messageSendParams.ToChatMessages();
var result = sendMessageRequest.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection()
public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection()
{
var messageSendParams = new MessageSendParams
var sendMessageRequest = new SendMessageRequest
{
Message = new AgentMessage
Message = new Message
{
MessageId = "test-id",
Role = MessageRole.User,
Role = Role.User,
Parts = null!
}
};
var result = messageSendParams.ToChatMessages();
var result = sendMessageRequest.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage()
public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage()
{
var messageSendParams = new MessageSendParams
var sendMessageRequest = new SendMessageRequest
{
Message = new AgentMessage
Message = new Message
{
MessageId = "test-id",
Role = MessageRole.User,
Role = Role.User,
Parts =
[
new TextPart { Text = "Hello, world!" }
new Part { Text = "Hello, world!" }
]
}
};
var result = messageSendParams.ToChatMessages();
var result = sendMessageRequest.ToChatMessages();
Assert.NotNull(result);
Assert.Single(result);
@@ -82,4 +83,68 @@ public class MessageConverterTests
var textContent = Assert.IsType<TextContent>(chatMessage.Contents.First());
Assert.Equal("Hello, world!", textContent.Text);
}
[Fact]
public void ToParts_NullList_ReturnsEmptyList()
{
// Arrange
IList<ChatMessage>? messages = null;
// Act
var result = messages!.ToParts();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToParts_EmptyList_ReturnsEmptyList()
{
// Arrange
IList<ChatMessage> messages = [];
// Act
var result = messages.ToParts();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToParts_WithTextContent_ReturnsTextPart()
{
// Arrange
IList<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, "Hello from the agent!")
];
// Act
var result = messages.ToParts();
// Assert
Assert.Single(result);
Assert.Equal("Hello from the agent!", result[0].Text);
}
[Fact]
public void ToParts_WithMultipleMessages_ReturnsAllParts()
{
// Arrange
IList<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.Assistant, "Second message")
];
// Act
var result = messages.ToParts();
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("First message", result[0].Text);
Assert.Equal("Second message", result[1].Text);
}
}
@@ -1,479 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method.
/// </summary>
public sealed class EndpointRouteA2ABuilderExtensionsTests
{
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A(agentBuilder, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null agentBuilder.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapA2A(agentBuilder, "/a2a"));
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a");
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a");
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with string agent name and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a", taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with string agent name and agent card succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_WithAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a", agentCard);
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A((AIAgent)null!, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with AIAgent correctly maps the agent.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a");
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and agent card succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_WithAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", agentCard);
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { });
Assert.NotNull(result);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager.
/// </summary>
[Fact]
public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
ITaskManager taskManager = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A(taskManager, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that multiple agents can be mapped to different paths.
/// </summary>
[Fact]
public void MapA2A_MultipleAgents_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2A(agent1Builder, "/a2a/agent1");
app.MapA2A(agent2Builder, "/a2a/agent2");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that custom paths can be specified for A2A endpoints.
/// </summary>
[Fact]
public void MapA2A_WithCustomPath_AcceptsValidPath()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapA2A(agentBuilder, "/custom/a2a/path");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that task manager configuration callback is invoked correctly.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
bool configureCallbackInvoked = false;
// Act
app.MapA2A(agentBuilder, "/a2a", taskManager =>
{
configureCallbackInvoked = true;
Assert.NotNull(taskManager);
});
// Assert
Assert.True(configureCallbackInvoked);
}
/// <summary>
/// Verifies that agent card with all properties is accepted.
/// </summary>
[Fact]
public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A comprehensive test agent"
};
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
Assert.NotNull(result);
}
}
@@ -269,6 +269,29 @@ public class SampleSmokeTest
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
}
/// <summary>
/// Stress regression for the off-thread run-status race: after
/// <c>Run.ResumeAsync</c> returns at a halt boundary,
/// callers must observe a stable terminal status and never a transient
/// <see cref="RunStatus.Running"/>. Step9 is the canonical multi-response resume
/// sample; prior to the fix in <see cref="Execution.StreamingRunEventStream"/>,
/// its `runStatus.Should().Be(RunStatus.Idle)` assertion failed intermittently
/// on roughly 1-in-10 iterations under InProcess_OffThread.
/// </summary>
[Fact]
internal async Task Test_RunSample_Step9_OffThread_MultiResponseResume_StatusIsStableAsync()
{
const int Iterations = 50;
for (int i = 0; i < Iterations; i++)
{
using StringWriter writer = new();
_ = await Step9EntryPoint.RunAsync(
writer,
ExecutionEnvironment.InProcess_OffThread.ToWorkflowExecutionEnvironment());
}
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
+22
View File
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.1.1] - 2026-04-23
### Added
- **agent-framework-core**: Add `expected_output` ground-truth support to `evaluate_workflow` for similarity evaluators ([#5234](https://github.com/microsoft/agent-framework/pull/5234))
- **agent-framework-ag-ui**, **agent-framework-a2a**: Propagate `thread_id` and `forwarded_props` through AG-UI to A2A `context_id` ([#5383](https://github.com/microsoft/agent-framework/pull/5383))
- **samples**: Add second approval-required tool (`set_stop_loss`) to `concurrent_builder_tool_approval` sample ([#4875](https://github.com/microsoft/agent-framework/pull/4875))
- **agent-framework-core**: Add `SKIP_PARSING` sentinel for `FunctionTool.invoke` to bypass `Content`-wrapping and return raw function results ([#5424](https://github.com/microsoft/agent-framework/pull/5424))
### Changed
- **agent-framework-foundry-hosting**: Correct Development Status classifier from Beta (4) to Alpha (3) to match the package's lifecycle stage ([#5387](https://github.com/microsoft/agent-framework/pull/5387))
- **tests**: Add Python flaky test report workflow ([#5342](https://github.com/microsoft/agent-framework/pull/5342))
- **agent-framework-hyperlight**: Simplify host callback to pass raw Python results via `SKIP_PARSING`, switch `execute_code` input schema to a plain JSON-schema dict, and tighten public API surface ([#5424](https://github.com/microsoft/agent-framework/pull/5424))
### Fixed
- **agent-framework-openai**: Fix OpenAI Responses streaming to propagate `created_at` from the final `response.completed` event ([#5382](https://github.com/microsoft/agent-framework/pull/5382))
- **agent-framework-openai**: Fix `OpenAIEmbeddingClient` to use `AsyncOpenAI` for `/openai/v1` endpoints ([#5137](https://github.com/microsoft/agent-framework/pull/5137))
- **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125))
- **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414))
- **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384))
- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads
([#5424](https://github.com/microsoft/agent-framework/pull/5424))
## [1.1.0] - 2026-04-21
### Added
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260421"
version = "1.0.0b260423"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
@@ -6,13 +6,13 @@ from collections.abc import Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from anthropic import AsyncAnthropicBedrock
@@ -94,7 +94,7 @@ class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[A
aws_profile=settings.get("aws_profile"),
aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None,
base_url=settings.get("anthropic_bedrock_base_url"),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
super().__init__(
@@ -8,7 +8,6 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequenc
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Annotation,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
@@ -28,6 +27,7 @@ from agent_framework import (
tool,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
from agent_framework._types import _get_data_bytes_as_str # type: ignore
from agent_framework.observability import ChatTelemetryLayer
@@ -332,7 +332,7 @@ class RawAnthropicClient(
anthropic_client = AsyncAnthropic(
api_key=api_key_secret.get_secret_value(),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
# Initialize parent
@@ -604,7 +604,7 @@ class RawAnthropicClient(
run_options["betas"] = self._prepare_betas(options)
# extra headers
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}
run_options["extra_headers"] = {"User-Agent": get_user_agent()}
# Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user)
if user := run_options.pop("user", None):
@@ -6,13 +6,13 @@ from collections.abc import Awaitable, Callable, Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from anthropic import AsyncAnthropicFoundry
@@ -91,14 +91,14 @@ class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[A
base_url=base_url_setting,
api_key=api_key_value,
azure_ad_token_provider=azure_ad_token_provider,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
else:
anthropic_client = AsyncAnthropicFoundry(
resource=resource_setting,
api_key=api_key_value,
azure_ad_token_provider=azure_ad_token_provider,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
super().__init__(
@@ -6,13 +6,13 @@ from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework._settings import load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from anthropic import NOT_GIVEN, AsyncAnthropicVertex
@@ -89,7 +89,7 @@ class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[An
access_token=access_token,
credentials=credentials,
base_url=settings.get("anthropic_vertex_base_url"),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
super().__init__(
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -3,7 +3,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer
from agent_framework import ChatMiddlewareLayer, FunctionInvocationLayer
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_anthropic import (
@@ -61,7 +62,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path)
resource="test-resource",
api_key="test-key",
azure_ad_token_provider=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -85,7 +86,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings(
base_url="https://test-resource.services.ai.azure.com/anthropic/",
api_key="test-key",
azure_ad_token_provider=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -130,7 +131,7 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non
aws_profile=None,
aws_session_token=None,
base_url=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -152,5 +153,5 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None
access_token=None,
credentials=None,
base_url=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -14,7 +14,6 @@ from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentSession,
Annotation,
Content,
@@ -25,6 +24,7 @@ from agent_framework import (
SupportsGetEmbeddings,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework.exceptions import SettingNotFoundError
from azure.core.credentials import AzureKeyCredential, TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -535,7 +535,7 @@ class AzureAISearchContextProvider(ContextProvider):
endpoint=self.endpoint,
index_name=self.index_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
self._index_client: SearchIndexClient | None = None
@@ -544,7 +544,7 @@ class AzureAISearchContextProvider(ContextProvider):
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
self._knowledge_base_initialized = False
@@ -640,7 +640,7 @@ class AzureAISearchContextProvider(ContextProvider):
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
if not self.index_name:
logger.warning("Cannot auto-discover vector field: index_name is not set.")
@@ -740,7 +740,7 @@ class AzureAISearchContextProvider(ContextProvider):
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
self._knowledge_base_initialized = True
return
@@ -802,7 +802,7 @@ class AzureAISearchContextProvider(ContextProvider):
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
async def _agentic_search(self, messages: list[Message]) -> list[Message]:
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -7,8 +7,8 @@ from __future__ import annotations
import logging
from typing import Any, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from agent_framework.exceptions import WorkflowCheckpointException
@@ -194,7 +194,7 @@ class CosmosCheckpointStorage:
self._cosmos_client = CosmosClient(
url=settings["endpoint"], # type: ignore[arg-type]
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
user_agent_suffix=get_user_agent(),
)
self._owns_client = True
@@ -10,9 +10,10 @@ import uuid
from collections.abc import Sequence
from typing import Any, ClassVar, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
from agent_framework import Message
from agent_framework._sessions import HistoryProvider
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.cosmos import PartitionKey
@@ -121,7 +122,7 @@ class CosmosHistoryProvider(HistoryProvider):
self._cosmos_client = CosmosClient(
url=settings["endpoint"], # type: ignore[arg-type]
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
user_agent_suffix=get_user_agent(),
)
self._owns_client = True
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
@@ -13,7 +13,6 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict
from uuid import uuid4
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
@@ -31,6 +30,7 @@ from agent_framework import (
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.exceptions import ChatClientInvalidResponseException
from agent_framework.observability import ChatTelemetryLayer
from boto3.session import Session as Boto3Session
@@ -299,7 +299,7 @@ class BedrockChatClient(
self._bedrock_client = session.client(
"bedrock-runtime",
region_name=region,
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
config=BotoConfig(user_agent_extra=get_user_agent()),
)
super().__init__(
@@ -11,7 +11,6 @@ from collections.abc import Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseEmbeddingClient,
Embedding,
EmbeddingGenerationOptions,
@@ -20,6 +19,7 @@ from agent_framework import (
UsageDetails,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import EmbeddingTelemetryLayer
from boto3.session import Session as Boto3Session
from botocore.client import BaseClient
@@ -140,7 +140,7 @@ class RawBedrockEmbeddingClient(
self._bedrock_client = boto3_session.client(
"bedrock-runtime",
region_name=region_name or resolved_region,
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
config=BotoConfig(user_agent_extra=get_user_agent()),
)
self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -125,6 +125,7 @@ from ._telemetry import (
prepend_agent_framework_to_user_agent,
)
from ._tools import (
SKIP_PARSING,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
@@ -258,6 +259,7 @@ __all__ = [
"GROUP_INDEX_KEY",
"GROUP_KIND_KEY",
"GROUP_TOKEN_COUNT_KEY",
"SKIP_PARSING",
"SUMMARIZED_BY_SUMMARY_ID_KEY",
"SUMMARY_OF_GROUP_IDS_KEY",
"SUMMARY_OF_MESSAGE_IDS_KEY",
@@ -4,9 +4,6 @@ from __future__ import annotations
import logging
import os
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Final
from . import __version__ as version_info
@@ -29,34 +26,73 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
# This environment variable is reserved by the Foundry hosting environment to
# indicate that the agent is running in a hosted environment.
_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT"
# This prefix is added to the user agent string when the agent is running in a hosted environment.
_HOSTED_USER_AGENT_PREFIX = "foundry-hosting"
_user_agent_prefixes: set[str] = set()
_hosted_env_detected: bool = False
@contextmanager
def user_agent_prefix(prefix: str) -> Generator[None]:
"""Context manager that adds a prefix to the user agent string for the current scope.
def _add_user_agent_prefix(prefix: str) -> None:
"""Permanently add a prefix to the user agent string.
This is useful for upstream layers that want to identify themselves in telemetry
for the duration of a request without permanently mutating global state.
This is used by hosting layers to identify themselves in telemetry.
Once added, the prefix applies to all subsequent user agent strings.
Args:
prefix: The prefix to add (e.g. "foundry-hosting").
"""
current = _user_agent_prefixes.get()
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
if prefix:
_user_agent_prefixes.add(prefix)
def _detect_hosted_environment() -> None:
"""Detect if running in a hosted environment and add the user agent prefix.
Checks the ``FOUNDRY_HOSTING_ENVIRONMENT`` env var first, then falls back
to checking whether the agent server SDK is installed (via
``importlib.util.find_spec``) before importing it, to avoid unnecessary
import overhead for non-hosted scenarios.
"""
global _hosted_env_detected
if _hosted_env_detected:
return
_hosted_env_detected = True
env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)
if env_value is not None:
# Env var exists — trust its value and skip the fallback.
if env_value:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
return
# Env var not set — fall back to AgentConfig as a second layer of defense.
# Use find_spec to avoid the cost of a full import when the SDK is not installed.
import importlib.util
try:
yield
finally:
if token is not None:
_user_agent_prefixes.reset(token)
if importlib.util.find_spec("azure.ai.agentserver.core") is None:
return
except (ModuleNotFoundError, ValueError):
return
try:
from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports]
if AgentConfig.from_env().is_hosted:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
except (ImportError, AttributeError):
pass
def _get_user_agent() -> str:
"""Return the full user agent string including any context-scoped prefixes."""
prefixes = _user_agent_prefixes.get()
if not prefixes:
def get_user_agent() -> str:
"""Return the full user agent string including any registered prefixes."""
_detect_hosted_environment()
if not _user_agent_prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -89,7 +125,7 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
user_agent = _get_user_agent()
user_agent = get_user_agent()
if not headers:
return {USER_AGENT_KEY: user_agent}
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
+88 -12
View File
@@ -94,6 +94,33 @@ ApprovalMode: TypeAlias = Literal["always_require", "never_require"]
ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
class _SkipParsingSentinel:
"""Sentinel signaling that :meth:`FunctionTool.invoke` should return the raw value.
When passed as ``result_parser`` to :class:`FunctionTool` (or the ``@tool`` decorator),
the default :meth:`FunctionTool.parse_result` is bypassed and the wrapped function's
return value is returned unchanged from :meth:`FunctionTool.invoke`. Callers may also
request the raw value on a per-call basis by passing ``skip_parsing=True`` to
:meth:`FunctionTool.invoke`.
Use the module-level ``SKIP_PARSING`` singleton — do not instantiate this class.
"""
_instance: ClassVar[_SkipParsingSentinel | None] = None
def __new__(cls) -> _SkipParsingSentinel:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __repr__(self) -> str:
return "SKIP_PARSING"
SKIP_PARSING: Final[_SkipParsingSentinel] = _SkipParsingSentinel()
"""Sentinel for ``FunctionTool(result_parser=...)`` meaning "do not parse the result"."""
# region Helpers
@@ -279,7 +306,7 @@ class FunctionTool(SerializationMixin):
additional_properties: dict[str, Any] | None = None,
func: Callable[..., Any] | None = None,
input_model: type[BaseModel] | Mapping[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
**kwargs: Any,
) -> None:
"""Initialize the FunctionTool.
@@ -327,9 +354,11 @@ class FunctionTool(SerializationMixin):
result_parser: An optional callable with signature ``Callable[[Any], str]`` that
overrides the default result parsing behavior. When provided, this callable
is used to convert the raw function return value to a string instead of the
built-in :meth:`parse_result` logic. Depending on your function, it may be
easiest to just do the serialization directly in the function body rather
than providing a custom ``result_parser``.
built-in :meth:`parse_result` logic. Pass the :data:`SKIP_PARSING` sentinel
instead of a callable to opt out of parsing entirely; in that case
:meth:`invoke` returns the wrapped function's raw return value. Depending
on your function, it may be easiest to just do the serialization directly
in the function body rather than providing a custom ``result_parser``.
**kwargs: Additional keyword arguments.
"""
# Core attributes (formerly from BaseTool)
@@ -508,31 +537,65 @@ class FunctionTool(SerializationMixin):
self.invocation_exception_count += 1
raise
@overload
async def invoke(
self,
*,
arguments: BaseModel | Mapping[str, Any] | None = None,
context: FunctionInvocationContext | None = None,
tool_call_id: str | None = None,
skip_parsing: Literal[True],
**kwargs: Any,
) -> list[Content]:
) -> Any: ...
@overload
async def invoke(
self,
*,
arguments: BaseModel | Mapping[str, Any] | None = None,
context: FunctionInvocationContext | None = None,
tool_call_id: str | None = None,
skip_parsing: Literal[False] = False,
**kwargs: Any,
) -> list[Content]: ...
async def invoke(
self,
*,
arguments: BaseModel | Mapping[str, Any] | None = None,
context: FunctionInvocationContext | None = None,
tool_call_id: str | None = None,
skip_parsing: bool = False,
**kwargs: Any,
) -> list[Content] | Any:
"""Run the AI function with the provided arguments as a Pydantic model.
The raw return value of the wrapped function is automatically parsed into a
``list[Content]`` using :meth:`parse_result` or the custom ``result_parser``
if one was provided. Every result — text, rich media, or serialized objects —
is represented uniformly as Content items.
configured on the tool. Every result — text, rich media, or serialized
objects — is represented uniformly as Content items.
Parsing can be skipped in two ways: configure the tool with
``result_parser=SKIP_PARSING`` to always skip parsing, or pass
``skip_parsing=True`` per call. Either way the wrapped function's raw value
is returned. This is intended for callers (e.g. sandboxed runtimes) that
consume the value from Python directly and would otherwise undo the
``Content`` wrapping.
Keyword Args:
arguments: A mapping or model instance containing the arguments for the function.
context: Explicit function invocation context carrying runtime kwargs.
tool_call_id: Optional tool call identifier used for telemetry and tracing.
skip_parsing: When ``True``, bypass parsing and return the wrapped function's
raw value instead of a ``list[Content]``. Defaults to ``False``.
kwargs: Direct function argument values. When provided, every keyword
must match a declared tool parameter. Runtime data must be passed
via ``context``.
Returns:
A list of Content items representing the tool output.
``list[Content]`` by default. The raw function return value (``Any``) when
``skip_parsing=True`` (or the tool was constructed with
``result_parser=SKIP_PARSING``).
Raises:
TypeError: If arguments is not mapping-like or fails schema checks.
@@ -544,7 +607,9 @@ class FunctionTool(SerializationMixin):
from ._types import Content
from .observability import OBSERVABILITY_SETTINGS
parser = self.result_parser or FunctionTool.parse_result
configured_parser = self.result_parser
skip_parsing = skip_parsing or configured_parser is SKIP_PARSING
parser = configured_parser if callable(configured_parser) else FunctionTool.parse_result
parameter_names = set(self.parameters().get("properties", {}).keys())
direct_argument_kwargs = (
@@ -616,6 +681,10 @@ class FunctionTool(SerializationMixin):
logger.debug(f"Function arguments: {observable_kwargs}")
res = self.__call__(**call_kwargs)
result = await res if inspect.isawaitable(res) else res
if skip_parsing:
logger.info(f"Function {self.name} succeeded.")
logger.debug(f"Function result: {type(result).__name__}")
return result
try:
parsed = parser(result)
except Exception:
@@ -671,6 +740,13 @@ class FunctionTool(SerializationMixin):
logger.error(f"Function failed. Error: {exception}")
raise
else:
if skip_parsing:
logger.info(f"Function {self.name} succeeded.")
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
result_str = str(result)
span.set_attribute(OtelAttr.TOOL_RESULT, result_str)
logger.debug(f"Function result: {result_str}")
return result
try:
parsed = parser(result)
except Exception:
@@ -1067,7 +1143,7 @@ def tool(
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
) -> FunctionTool: ...
@@ -1083,7 +1159,7 @@ def tool(
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
@@ -1098,7 +1174,7 @@ def tool(
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
"""Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically.
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.1.0"
version = "1.1.1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+178 -41
View File
@@ -1,14 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import patch
import os
from unittest.mock import MagicMock, patch
import agent_framework._telemetry as _telemetry_mod
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
USER_AGENT_KEY,
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
prepend_agent_framework_to_user_agent,
)
from agent_framework._telemetry import user_agent_prefix
from agent_framework._telemetry import (
_FOUNDRY_HOSTING_ENV_VAR,
_HOSTED_USER_AGENT_PREFIX,
_add_user_agent_prefix,
_detect_hosted_environment,
)
# region Test constants
@@ -83,7 +90,7 @@ def test_prepend_to_empty_headers():
def test_prepend_to_empty_dict():
"""Test prepending to empty headers dict."""
headers = {}
headers: dict[str, str] = {}
result = prepend_agent_framework_to_user_agent(headers)
assert "User-Agent" in result
@@ -99,54 +106,184 @@ def test_modifies_original_dict():
assert "User-Agent" in headers
# region Test user_agent_prefix context manager
# region Test _add_user_agent_prefix
def test_user_agent_prefix_adds_prefix():
"""Test that the context manager adds a prefix within its scope."""
with user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
# Prefix is removed after exiting the context
def test_add_user_agent_prefix_adds_prefix():
"""Test that _add_user_agent_prefix permanently adds a prefix."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("test-host")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
_telemetry_mod._user_agent_prefixes.clear()
def test_user_agent_prefix_ignores_duplicates():
"""Test that duplicate prefixes are not added within nested scopes."""
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].count("test-host") == 1
def test_add_user_agent_prefix_ignores_duplicates():
"""Test that duplicate prefixes are not added."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("test-host")
_add_user_agent_prefix("test-host")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].count("test-host") == 1
_telemetry_mod._user_agent_prefixes.clear()
def test_user_agent_prefix_ignores_empty():
def test_add_user_agent_prefix_ignores_empty():
"""Test that empty strings are not added as prefixes."""
with user_agent_prefix(""):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_restores_on_exit():
"""Test that prefixes are fully restored after the context manager exits."""
with user_agent_prefix("test-host"):
pass
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
_telemetry_mod._user_agent_prefixes.clear()
def test_user_agent_prefix_nesting():
"""Test that nested context managers compose prefixes correctly."""
with user_agent_prefix("outer"):
with user_agent_prefix("inner"):
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
# Inner prefix removed
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" not in result["User-Agent"]
# Both removed
def test_add_user_agent_prefix_multiple():
"""Test that multiple prefixes compose correctly."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("outer")
_add_user_agent_prefix("inner")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
_telemetry_mod._user_agent_prefixes.clear()
# region Test _detect_hosted_environment
def test_detect_hosted_env_var_truthy_adds_prefix():
"""Test that a truthy FOUNDRY_HOSTING_ENVIRONMENT env var adds the prefix."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_env_var_empty_skips_prefix():
"""Test that an empty FOUNDRY_HOSTING_ENVIRONMENT env var does NOT add the prefix."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: ""}):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_env_var_set_skips_agent_config_fallback():
"""Test that when the env var is set, AgentConfig is never consulted even if import would fail."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
import builtins
real_import = builtins.__import__
def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if "agentserver" in name:
raise AssertionError("AgentConfig should not be imported when env var is set")
return real_import(name, *args, **kwargs)
with (
patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "prod"}),
patch("builtins.__import__", side_effect=_block_agentconfig),
):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def _mock_agent_config(*, is_hosted: bool) -> MagicMock:
"""Create a mock azure.ai.agentserver.core module with AgentConfig."""
mock_config = MagicMock()
mock_config.is_hosted = is_hosted
mock_module = MagicMock()
mock_module.AgentConfig.from_env.return_value = mock_config
return mock_module
def test_detect_hosted_fallback_agent_config_is_hosted():
"""Test that AgentConfig fallback adds the prefix when is_hosted is True."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
mock_module = _mock_agent_config(is_hosted=True)
mock_spec = MagicMock()
with (
patch.dict("os.environ", env, clear=True),
patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}),
patch("importlib.util.find_spec", return_value=mock_spec),
):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_fallback_agent_config_not_hosted():
"""Test that AgentConfig fallback does NOT add the prefix when is_hosted is False."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
mock_module = _mock_agent_config(is_hosted=False)
mock_spec = MagicMock()
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
with (
patch.dict("os.environ", env, clear=True),
patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}),
patch("importlib.util.find_spec", return_value=mock_spec),
):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_fallback_import_error():
"""Test that ImportError from AgentConfig is silently handled."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
with patch.dict("os.environ", env, clear=True):
# The real import may succeed or fail depending on the environment;
# force the ImportError path by making the import raise.
import builtins
real_import = builtins.__import__
def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if "agentserver" in name:
raise ImportError("mocked")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=_block_agentconfig):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
# region Test module-level auto-detection
def test_lazy_detection_on_get_user_agent():
"""Test that get_user_agent() lazily detects the hosted environment.
Since detection is deferred to the first ``get_user_agent()`` call,
this verifies the prefix is included without any explicit call to
``_detect_hosted_environment()`` by consumer code.
"""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}):
user_agent = _telemetry_mod.get_user_agent()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
assert user_agent.startswith(f"{_HOSTED_USER_AGENT_PREFIX}/")
# Clean up
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
@@ -8,6 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from pydantic import BaseModel
from agent_framework import (
SKIP_PARSING,
Content,
FunctionTool,
tool,
@@ -1300,4 +1301,165 @@ def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None
assert normalized[1] is standalone
# region SKIP_PARSING sentinel & skip_parsing
async def test_invoke_skip_parsing_returns_native_value() -> None:
"""invoke(skip_parsing=True) returns the wrapped function's raw value."""
@tool
def get_weather(city: str) -> dict[str, Any]:
"""Get the weather."""
return {"city": city, "temperature_c": 21.5, "conditions": "partly cloudy"}
raw = await get_weather.invoke(arguments={"city": "Seattle"}, skip_parsing=True)
assert isinstance(raw, dict)
assert raw == {"city": "Seattle", "temperature_c": 21.5, "conditions": "partly cloudy"}
async def test_invoke_skip_parsing_passes_through_custom_objects() -> None:
"""skip_parsing must not call str()/repr() on the result."""
class Custom: # noqa: B903
def __init__(self, value: int) -> None:
self.value = value
@tool
def make() -> Custom:
"""Make a custom object."""
return Custom(42)
raw = await make.invoke(skip_parsing=True)
assert isinstance(raw, Custom)
assert raw.value == 42
async def test_invoke_skip_parsing_awaits_async_functions() -> None:
@tool
async def slow(x: int) -> int:
"""Async tool."""
return x * 2
raw = await slow.invoke(arguments={"x": 21}, skip_parsing=True)
assert raw == 42
async def test_invoke_skip_parsing_bypasses_configured_result_parser() -> None:
"""The tool's own result_parser is bypassed when skip_parsing=True is requested."""
parser_calls: list[Any] = []
def parser(value: Any) -> str:
parser_calls.append(value)
return "PARSED"
@tool(result_parser=parser)
def make_dict() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1}
raw = await make_dict.invoke(skip_parsing=True)
assert raw == {"a": 1}
assert parser_calls == []
# Sanity: omitting skip_parsing still applies the configured parser.
parsed = await make_dict.invoke()
assert parsed[0].type == "text"
assert parsed[0].text == "PARSED"
async def test_constructor_skip_parsing_sentinel_returns_raw_by_default() -> None:
"""Constructing a tool with result_parser=SKIP_PARSING makes invoke return the raw value."""
@tool(result_parser=SKIP_PARSING)
def make_dict() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1}
raw = await make_dict.invoke()
assert raw == {"a": 1}
async def test_invoke_skip_parsing_validates_arguments() -> None:
"""Argument validation is shared with the default path."""
@tool
def adder(x: int, y: int) -> int:
"""Add."""
return x + y
with pytest.raises(TypeError):
await adder.invoke(arguments={"x": "not-an-int", "y": 1}, skip_parsing=True)
async def test_invoke_skip_parsing_rejects_unexpected_runtime_kwargs() -> None:
@tool
async def echo(message: str) -> str:
"""Echo."""
return message
with pytest.raises(TypeError, match="Unexpected keyword argument"):
await echo.invoke(arguments={"message": "hi"}, skip_parsing=True, api_token="secret")
async def test_invoke_skip_parsing_raises_for_declaration_only_tool() -> None:
declared = FunctionTool(name="dummy", description="declaration only")
from agent_framework.exceptions import ToolException
with pytest.raises(ToolException):
await declared.invoke(arguments={}, skip_parsing=True)
async def test_invoke_skip_parsing_records_telemetry(span_exporter: InMemorySpanExporter) -> None:
"""skip_parsing participates in OTEL spans and records str(raw) as TOOL_RESULT."""
@tool(name="raw_tool", description="raw tool")
def returns_dict(x: int) -> dict[str, int]:
"""Returns a dict."""
return {"value": x}
span_exporter.clear()
raw = await returns_dict.invoke(arguments={"x": 5}, tool_call_id="raw_call", skip_parsing=True)
assert raw == {"value": 5}
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes[OtelAttr.TOOL_NAME] == "raw_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "raw_call"
assert span.attributes[OtelAttr.TOOL_RESULT] == "{'value': 5}"
async def test_invoke_default_path_records_parsed_telemetry(
span_exporter: InMemorySpanExporter,
) -> None:
"""Regression: omitting skip_parsing still records the parsed result in telemetry."""
def parser(value: Any) -> str:
return f"parsed:{value}"
@tool(name="parsed_tool", description="parsed", result_parser=parser)
def returns_int() -> int:
"""Returns an int."""
return 7
span_exporter.clear()
parsed = await returns_int.invoke(tool_call_id="parsed_call")
assert parsed[0].text == "parsed:7"
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].attributes[OtelAttr.TOOL_RESULT] == "parsed:7"
def test_skip_parsing_is_singleton() -> None:
"""SKIP_PARSING is a singleton; instantiation returns the same object."""
from agent_framework._tools import _SkipParsingSentinel
assert _SkipParsingSentinel() is SKIP_PARSING
assert repr(SKIP_PARSING) == "SKIP_PARSING"
# endregion
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]

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