Compare commits

..
Author SHA1 Message Date
SergeyMenshykhandCopilot 547316b523 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>
2026-04-22 16:25:16 +01:00
SergeyMenshykh 424d66ad74 fix formatting issues 2026-04-22 13:44:22 +01:00
SergeyMenshykh cd1f4c2a93 address automated code review and formatting issues 2026-04-22 13:31:14 +01:00
SergeyMenshykh 8d208f3bb3 address copilot initial feedback 2026-04-22 12:51:51 +01:00
SergeyMenshykh 373482427c restore AsyncEnumerable package version 2026-04-22 11:44:54 +01:00
SergeyMenshykhandGitHub c84203a4a8 Merge branch 'main' into a2a-agent-migration 2026-04-22 11:27:12 +01:00
SergeyMenshykhandCopilot 8f878dcd58 Remove unnecessary using directive in AgentWebChat.AgentHost
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 11:20:15 +01:00
c54483f81e .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>
2026-04-22 10:38:48 +01:00
275363d15e .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>
2026-04-21 15:31:56 +01:00
7417eeb7e6 .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>
2026-04-15 17:29:36 +01:00
6ad9279f0f .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>
2026-04-15 14:45:11 +01:00
c1bbaeb31d 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>
2026-04-15 12:08:21 +01:00
SergeyMenshykhandGitHub 6173e63f0b update a2a agent to the latest a2a sdk (#5257) 2026-04-15 11:08:05 +01:00
159 changed files with 739 additions and 13188 deletions
-61
View File
@@ -1,61 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Resolve the issue author and check their team membership.
*
* @param {object} opts
* @param {object} opts.github - Octokit REST client from actions/github-script
* @param {object} opts.context - GitHub Actions context
* @param {object} opts.core - GitHub Actions core toolkit
* @param {string} opts.teamSlug - Team slug to check membership against
* @param {string|number} opts.issueNumber - Issue number to resolve author for
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
*/
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
let author = context.payload.issue?.user?.login;
if (!author) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(issueNumber),
});
author = issue.user?.login;
}
if (!author) {
core.setFailed('Could not determine issue author (user may be deleted).');
return { author: null, isTeamMember: false };
}
try {
await github.rest.teams.getByName({
org: context.repo.owner,
team_slug: teamSlug,
});
} catch (error) {
core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`);
throw error;
}
let isTeamMember = false;
try {
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: teamSlug,
username: author,
});
isTeamMember = teamMembership.data.state === 'active';
} catch (error) {
if (error.status === 404) {
core.info(`Author ${author} is not a member of team ${teamSlug}.`);
isTeamMember = false;
} else {
core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`);
throw error;
}
}
return { author, isTeamMember };
}
module.exports = checkTeamMembership;
-178
View File
@@ -1,178 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for check_team_membership.js.
*
* Run with: node --test .github/tests/test_check_team_membership.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const checkTeamMembership = require('../scripts/check_team_membership.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
const core = {
_infoMessages: [],
_failedMessages: [],
info(msg) { this._infoMessages.push(msg); },
setFailed(msg) { this._failedMessages.push(msg); },
};
const context = {
payload: { issue: payloadIssue },
repo: { owner: 'test-org', repo: 'test-repo' },
};
const github = {
rest: {
issues: {
get: async () => ({
data: { user: apiUser ? { login: apiUser } : null },
}),
},
teams: {
getByName: async () => ({}),
getMembershipForUserInOrg: async () => ({
data: { state: teamState },
}),
},
},
};
return { core, context, github };
}
const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
// ---------------------------------------------------------------------------
// Author resolution
// ---------------------------------------------------------------------------
describe('author resolution', () => {
it('resolves author from event payload', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'payload-user' } },
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'payload-user');
});
it('resolves author via API when payload issue is absent', async () => {
const { github, context, core } = createMocks({ apiUser: 'api-user' });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'api-user');
});
it('resolves author via API when payload issue user is null (deleted account)', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: null },
apiUser: 'fetched-user',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'fetched-user');
});
it('handles deleted account when API also returns null user', async () => {
const { github, context, core } = createMocks({ apiUser: null });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, null);
assert.equal(result.isTeamMember, false);
assert.ok(core._failedMessages.some(m => m.includes('deleted')));
});
});
// ---------------------------------------------------------------------------
// Team lookup
// ---------------------------------------------------------------------------
describe('team lookup', () => {
it('fails the job when team lookup errors', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const error = new Error('Bad credentials');
github.rest.teams.getByName = async () => { throw error; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === error,
);
assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed')));
});
});
// ---------------------------------------------------------------------------
// Team membership
// ---------------------------------------------------------------------------
describe('team membership', () => {
it('returns true for active team member', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'member' } },
teamState: 'active',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, true);
});
it('returns false for pending team member', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'pending-user' } },
teamState: 'pending',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, false);
});
it('treats 404 membership response as non-member without failing', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'outsider' } },
});
const notFoundError = new Error('Not Found');
notFoundError.status = 404;
github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; };
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, false);
assert.equal(core._failedMessages.length, 0);
assert.ok(core._infoMessages.some(m => m.includes('not a member')));
});
it('fails the job on non-404 membership errors', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const serverError = new Error('Internal Server Error');
serverError.status = 500;
github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === serverError,
);
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
});
it('fails the job on membership errors without status code', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const networkError = new Error('ECONNREFUSED');
github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === networkError,
);
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
});
});
-4
View File
@@ -108,10 +108,6 @@ 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
@@ -1,199 +0,0 @@
name: Issue Triage
on:
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
permissions:
contents: read
issues: write
id-token: write
concurrency:
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
env:
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
DEVFLOW_REF: main
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
DEVFLOW_PATH: ${{ github.workspace }}/devflow
jobs:
team_check:
runs-on: ubuntu-latest
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
repo: ${{ steps.issue.outputs.repo }}
steps:
- name: Resolve issue metadata
id: issue
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
issue_number="${ISSUE_NUMBER_EVENT}"
else
issue_number="${ISSUE_NUMBER_INPUT}"
fi
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
exit 1
fi
echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT"
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Check issue author team membership
id: check
uses: actions/github-script@v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: process.env.ISSUE_NUMBER,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; skipping auto-triage.`);
} else {
core.info(`Author ${author} is not a team member; proceeding with triage.`);
}
triage:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
environment: integration
timeout-minutes: 60
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
path: target-repo
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
token: ${{ secrets.DEVFLOW_TOKEN }}
fetch-depth: 1
persist-credentials: false
path: devflow
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
- name: Install DevFlow dependencies
working-directory: ${{ env.DEVFLOW_PATH }}
run: uv sync --frozen
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Classify issue relevance
id: spam
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
run: |
uv run python scripts/classify_issue_spam.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
--repo-path "${TARGET_REPO_PATH}" \
--apply-labels
- name: Stop after spam gate
if: ${{ steps.spam.outputs.decision != 'allow' }}
shell: bash
env:
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
run: |
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
exit 1
- name: Reproduce reported issue
if: ${{ steps.spam.outputs.decision == 'allow' }}
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
# Model-provider settings for generated repro code. Never enter the
# agent prompt; consumed by SDK constructors via os.environ. Azure
# OpenAI and Foundry auth via AAD from the azure/login step above.
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
run: |
uv run python scripts/trigger_issue_repro.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
--github-username "$GITHUB_ACTOR"
+1 -114
View File
@@ -87,14 +87,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure OpenAI integration tests
python-tests-azure-openai:
@@ -138,14 +130,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Misc integration tests (Anthropic, Hyperlight, Ollama, MCP)
python-tests-misc-integration:
@@ -189,14 +173,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 30
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
if-no-files-found: ignore
- name: Stop local MCP server
if: always()
shell: bash
@@ -273,14 +249,6 @@ jobs:
-x
--timeout=360 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
if-no-files-found: ignore
# Foundry integration tests
python-tests-foundry:
@@ -327,14 +295,6 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure Cosmos integration tests
python-tests-cosmos:
@@ -379,80 +339,7 @@ jobs:
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
if-no-files-found: ignore
# Flaky test trend report (aggregates per-job JUnit XML results)
python-flaky-test-report:
name: Flaky Test Report
if: >
always() &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs:
[
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-foundry,
python-tests-cosmos,
]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
restore-keys: |
flaky-report-history-integration-
- name: Generate trend report
run: >
uv run python scripts/flaky_report/aggregate.py
../test-results/
flaky-report-history.json
flaky-test-report.md
- name: Post to Job Summary
if: always()
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: flaky-test-report
path: |
python/flaky-test-report.md
python/flaky-report-history.json
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
python-integration-tests-check:
if: always()
+1 -110
View File
@@ -181,13 +181,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure OpenAI integration tests
python-tests-azure-openai:
@@ -251,13 +244,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Azure OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Misc integration tests (Anthropic, Ollama, MCP)
python-tests-misc-integration:
@@ -335,13 +321,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Misc integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure Functions + Durable Task integration tests
python-tests-functions:
@@ -413,13 +392,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Functions integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
if-no-files-found: ignore
python-tests-foundry:
name: Python Integration Tests - Foundry
@@ -437,10 +409,6 @@ jobs:
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
@@ -480,13 +448,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
if-no-files-found: ignore
# TODO: Add python-tests-lab
@@ -536,7 +497,7 @@ jobs:
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml
working-directory: ./python
- name: Surface failing tests
if: always()
@@ -547,76 +508,6 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Cosmos integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
if-no-files-found: ignore
# Flaky test trend report (aggregates per-job JUnit XML results)
python-flaky-test-report:
name: Flaky Test Report
if: >
always() &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs:
[
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-foundry,
python-tests-cosmos,
]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
restore-keys: |
flaky-report-history-merge-
- name: Generate trend report
run: >
uv run python scripts/flaky_report/aggregate.py
../test-results/
flaky-report-history.json
flaky-test-report.md
- name: Post to Job Summary
if: always()
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: flaky-test-report
path: |
python/flaky-test-report.md
python/flaky-report-history.json
python-integration-tests-check:
if: always()
+4 -4
View File
@@ -22,9 +22,9 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.22" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.1" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
@@ -188,4 +188,4 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
+3 -3
View File
@@ -4,6 +4,9 @@
<BuildType Name="Publish" />
<BuildType Name="Release" />
</Configurations>
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
</Folder>
<Folder Name="/Samples/">
<File Path="samples/AGENTS.md" />
<File Path="samples/README.md" />
@@ -64,7 +67,6 @@
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -160,7 +162,6 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
@@ -532,7 +533,6 @@
<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" />
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.3.0</VersionPrefix>
<VersionPrefix>1.2.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260423</DateSuffix>
<DateSuffix>260421</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.3.0</GitTag>
<GitTag>1.2.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -1,281 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to dynamically expand the set of function tools available to an
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
// When the model calls RequestTools with a description of the capabilities needed, the function
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// Pre-defined tool implementations that can be loaded on demand.
[Description("Get the current weather for a city.")]
static string GetWeather([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
"NEW YORK" => "New York: 72°F, sunny and warm.",
"LONDON" => "London: 48°F, overcast with fog.",
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
};
[Description("Get the current local time for a city.")]
static string GetTime([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 9:00 AM PST",
"NEW YORK" => "New York: 12:00 PM EST",
"LONDON" => "London: 5:00 PM GMT",
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
};
[Description("Convert a temperature from Fahrenheit to Celsius.")]
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
// A registry of tool sets that can be loaded by description keyword.
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
{
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
};
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
AIFunction requestToolsFunction = AIFunctionFactory.Create(
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
"Call this when you need capabilities that are not yet available in your current tool set.")] (
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
) =>
{
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
var context = FunctionInvokingChatClient.CurrentContext
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
var tools = context.Options?.Tools;
if (tools is null)
{
return "Unable to register new tools: ChatOptions.Tools is not available.";
}
// Find matching tool sets from the catalog.
List<string> addedToolNames = [];
foreach (var kvp in toolCatalog)
{
var keyword = kvp.Key;
var catalogTools = kvp.Value;
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
foreach (var tool in catalogTools)
{
// Avoid adding duplicates.
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
{
tools.Add(tool);
addedToolNames.Add(fn.Name);
}
}
}
}
return addedToolNames.Count > 0
? "Successfully loaded tools"
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
},
name: "RequestTools");
// Create the agent with only the RequestTools function initially.
// Insert chat client middleware that logs the tools available on each LLM call,
// making the dynamic expansion visible in the console output.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
.BuildAIAgent(
instructions: """
You are a helpful assistant. You start with limited tools.
When you need functionality that you don't currently have, call RequestTools with a description
of what you need. After new tools are loaded, use them to answer the user's question.
""",
tools: [requestToolsFunction]);
// Run a conversation that triggers dynamic tool expansion.
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
string[] prompts =
[
"What's the weather like in Seattle and London?",
"What time is it in New York?",
"Can you convert those temperatures to Celsius?"
];
// --- Non-Streaming Mode ---
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("=== Non-Streaming Mode ===");
Console.ResetColor();
Console.WriteLine();
AgentSession session = await agent.CreateSessionAsync();
foreach (var prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
var response = await agent.RunAsync(prompt, session);
// Print all message contents including tool calls, tool results, and text.
foreach (var message in response.Messages)
{
foreach (var content in message.Contents)
{
switch (content)
{
case FunctionCallContent functionCall:
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
Console.ResetColor();
break;
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("[Agent] ");
Console.ResetColor();
Console.WriteLine(textContent.Text);
break;
}
}
}
Console.WriteLine();
}
// --- Streaming Mode ---
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("=== Streaming Mode ===");
Console.ResetColor();
Console.WriteLine();
AgentSession streamingSession = await agent.CreateSessionAsync();
foreach (var prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
bool inAgentText = false;
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
{
foreach (var content in update.Contents)
{
switch (content)
{
case FunctionCallContent functionCall:
if (inAgentText)
{
Console.WriteLine();
inAgentText = false;
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
Console.ResetColor();
break;
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
if (!inAgentText)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("[Agent] ");
Console.ResetColor();
inAgentText = true;
}
Console.Write(textContent.Text);
break;
}
}
}
if (inAgentText)
{
Console.WriteLine();
}
Console.WriteLine();
}
// Chat client middleware that logs the number and names of tools on each LLM request.
async Task<ChatResponse> ToolLoggingMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
CancellationToken cancellationToken)
{
LogTools(options);
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
}
// Streaming version of the tool logging middleware.
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
LogTools(options);
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
{
yield return update;
}
}
// Shared helper to log the current tool set.
void LogTools(ChatOptions? options)
{
if (options?.Tools is { Count: > 0 } tools)
{
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
Console.ResetColor();
}
else
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine(" [Middleware] LLM call with 0 tools");
Console.ResetColor();
}
}
@@ -1,38 +0,0 @@
# Dynamic Function Tools
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
## What it demonstrates
- The agent starts with only a single `RequestTools` function
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
## How it works
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
## Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
## Running the sample
Set the required environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
```
Run the sample:
```powershell
dotnet run
```
@@ -46,7 +46,6 @@ Before you begin, ensure you have the following prerequisites:
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
## Running the samples from the console
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
</ItemGroup>
</Project>
@@ -1,148 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
// tools when creating an agent. The Foundry platform handles tool execution — the agent
// process does not invoke tools locally.
using System.ClientModel;
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
#pragma warning disable OPENAI001 // Experimental API
#pragma warning disable AAIP001 // AgentToolboxes is experimental
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
// Replace with your own Foundry toolbox name.
const string ToolboxName = "research_toolbox";
// Used only by CombineToolboxes — swap in a second toolbox you own.
const string SecondToolboxName = "analysis_toolbox";
// Replace with any question that exercises the tools configured in your toolbox.
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
await Main(projectClient, model, endpoint);
// await CombineToolboxes(projectClient, model, endpoint);
// ---------------------------------------------------------------------------
// Main: single toolbox
// ---------------------------------------------------------------------------
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
// Comment out if the toolbox already exists in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
// Omit the version to resolve the toolbox's current default version at runtime.
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use the available tools to answer questions.",
tools: tools.ToList());
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Alternative: combine tools from multiple toolboxes
// ---------------------------------------------------------------------------
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
{
Console.WriteLine("=== Combine Toolboxes Example ===");
// Comment out if the toolboxes already exist in your Foundry project.
await CreateSampleToolboxAsync(ToolboxName, endpoint);
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
var allTools = toolboxA.Concat(toolboxB).ToList();
AIAgent agent = projectClient
.AsAIAgent(
model: model,
instructions: "You are a research assistant. Use all available tools to answer questions.",
tools: allTools);
Console.WriteLine($"User: {Query}");
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
}
// ---------------------------------------------------------------------------
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
// ---------------------------------------------------------------------------
static async Task CreateSampleToolboxAsync(string name, string endpoint)
{
// Toolboxes are normally configured in the Foundry portal or a deployment
// script, not the application itself. This helper exists so the sample can
// be run end-to-end without first setting a toolbox up by hand.
// The Foundry-Features header is currently required for toolbox CRUD operations.
var options = new AgentAdministrationClientOptions();
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
var adminClient = new AgentAdministrationClient(
new Uri(endpoint),
new DefaultAzureCredential(),
options);
var toolboxClient = adminClient.GetAgentToolboxes();
// Delete existing toolbox if present (ignore 404).
try
{
await toolboxClient.DeleteToolboxAsync(name);
Console.WriteLine($"Deleted existing toolbox '{name}'");
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Toolbox does not exist — nothing to delete.
}
// Create a fresh version with a single MCP tool.
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
serverLabel: "api-specs",
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
var created = (await toolboxClient.CreateToolboxVersionAsync(
name: name,
tools: [mcpTool],
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
}
// ---------------------------------------------------------------------------
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
// ---------------------------------------------------------------------------
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
{
private const string FeatureHeader = "Foundry-Features";
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Add(FeatureHeader, feature);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
@@ -1,46 +0,0 @@
# Agent_Step25_ToolboxServerSideTools
This sample demonstrates loading a named Foundry toolbox and passing its tools as
**server-side tools** when creating an agent via `AsAIAgent()`.
When tools from a toolbox are passed this way, they are sent as tool definitions in
the Responses API request. The Foundry platform handles tool execution — the agent
process does not invoke tools locally.
This is the dotnet equivalent of the Python sample:
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
## Prerequisites
- A Microsoft Foundry project
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
The sample recreates the toolbox on each run, replacing any existing toolbox with
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
an existing toolbox unchanged.
## How it works
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
Foundry project API (resolving the default version if none is specified)
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
API request as server-side tool definitions
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
## Sample flows
| Flow | Description |
|------|-------------|
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
Uncomment the desired flow in the top-level statements to try each one.
## Running the sample
```bash
dotnet run
```
@@ -2,22 +2,14 @@
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<IsPackable>true</IsPackable>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<!-- 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>
@@ -30,7 +22,4 @@
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
@@ -1,57 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
namespace Azure.AI.Projects;
/// <summary>
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
/// allowing a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class AIProjectClientToolboxExtensions
{
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="System.ArgumentNullException">
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
this AIProjectClient projectClient,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectClient);
Throw.IfNullOrWhitespace(name);
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
}
@@ -1,223 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
/// </summary>
/// <remarks>
/// <para>
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// <para>
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
{
/// <summary>
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically (requires an additional API call).
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
/// ready for use as server-side tools in the Responses API.
/// </summary>
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
/// <param name="name">The name of the toolbox to fetch.</param>
/// <param name="version">
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
/// default version is resolved automatically.
/// </param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version = null,
CancellationToken cancellationToken = default)
{
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
return toolboxVersion.ToAITools();
}
/// <summary>
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
/// suitable for use as server-side tools in the Responses API.
/// </summary>
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
/// platform handles their execution.
/// </para>
/// <para>
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
/// that the toolbox API returns but the Responses API rejects.
/// </para>
/// </remarks>
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
{
Throw.IfNull(toolboxVersion);
if (toolboxVersion.Tools?.Any() != true)
{
return [];
}
return toolboxVersion.Tools
.Select(SanitizeAndConvert)
.ToList();
}
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
/// <summary>
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
/// </summary>
/// <remarks>
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
/// these decoration fields for non-function tools. Function tools keep them since
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
/// </remarks>
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
{
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
var node = JsonNode.Parse(toolJson.ToString());
if (node is not JsonObject obj)
{
return ((ResponseTool)tool).AsAITool();
}
var toolType = obj["type"]?.GetValue<string>();
// Function tools need name/description — don't strip
if (toolType is "function" or "custom")
{
return ((ResponseTool)tool).AsAITool();
}
// Strip decoration fields that the Responses API rejects
bool modified = false;
modified |= obj.Remove("name");
modified |= obj.Remove("description");
if (!modified)
{
return ((ResponseTool)tool).AsAITool();
}
var sanitizedJson = obj.ToJsonString();
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
return sanitizedTool.AsAITool();
}
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
string name,
string? version,
AgentAdministrationClientOptions? clientOptions,
CancellationToken cancellationToken)
{
Throw.IfNull(projectEndpoint);
Throw.IfNull(credential);
Throw.IfNullOrWhitespace(name);
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
}
internal static AgentToolboxes CreateToolboxClient(
Uri projectEndpoint,
AuthenticationTokenProvider credential,
AgentAdministrationClientOptions? clientOptions = null)
{
clientOptions ??= new AgentAdministrationClientOptions();
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
return adminClient.GetAgentToolboxes();
}
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
AgentToolboxes toolboxClient,
string name,
string? version,
CancellationToken cancellationToken)
{
if (version is null)
{
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
version = record.Value.DefaultVersion
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
}
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
return result.Value;
}
#endregion
}
@@ -237,7 +237,7 @@ internal static class InputConverter
{
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
OutputItemReasoningItem => null,
_ => null
};
@@ -332,7 +332,7 @@ internal static class InputConverter
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
{
return new ChatMessage(
ChatRole.Tool,
@@ -251,25 +251,16 @@ internal static class OutputConverter
var outputTokens = details.OutputTokenCount ?? 0;
var totalTokens = details.TotalTokenCount ?? 0;
var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false
? cached : 0;
var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false
? reasoning : 0;
if (existing is not null)
{
inputTokens += existing.InputTokens;
outputTokens += existing.OutputTokens;
totalTokens += existing.TotalTokens;
cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0;
reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0;
}
return new ResponseUsage(
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
inputTokens: inputTokens,
inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens),
outputTokens: outputTokens,
outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens),
totalTokens: totalTokens);
}
@@ -42,19 +42,11 @@ internal sealed class A2AAgentHandler : IAgentHandler
/// <inheritdoc/>
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
// Handle task updates
if (context.IsContinuation)
{
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
}
// Handle messages received via streaming endpoint
if (context.StreamingResponse)
{
return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken);
}
// Handle new messages received via non-streaming endpoint
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
}
@@ -88,19 +80,13 @@ internal sealed class A2AAgentHandler : IAgentHandler
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
AgentResponse response;
try
{
response = await this._hostAgent.RunAsync(
chatMessages,
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
finally
{
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
}
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)
{
@@ -122,39 +108,6 @@ internal sealed class A2AAgentHandler : IAgentHandler
}
}
private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
}
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
var options = context.Metadata is { Count: > 0 }
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
: null;
try
{
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false))
{
var message = CreateMessageFromUpdate(contextId, update);
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
}
finally
{
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
}
}
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
@@ -188,10 +141,8 @@ internal sealed class A2AAgentHandler : IAgentHandler
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
throw;
}
finally
{
await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false);
}
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is null)
{
@@ -223,16 +174,6 @@ internal sealed class A2AAgentHandler : IAgentHandler
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
new()
{
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = Role.Agent,
Parts = update.ToParts(),
Metadata = update.AdditionalProperties?.ToA2AMetadata()
};
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
{
if (agentTask?.History is not { Count: > 0 })
@@ -8,26 +8,6 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
internal static class MessageConverter
{
public static List<Part> ToParts(this AgentResponseUpdate update)
{
if (update is null || update.Contents is not { Count: > 0 })
{
return [];
}
var parts = new List<Part>();
foreach (var content in update.Contents)
{
var part = content.ToPart();
if (part is not null)
{
parts.Add(part);
}
}
return parts;
}
public static List<Part> ToParts(this IList<ChatMessage> chatMessages)
{
if (chatMessages is null || chatMessages.Count == 0)
@@ -77,13 +77,12 @@ internal sealed class StreamingRunEventStream : IRunEventStream
try
{
// 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.
// Wait for the first input before starting
// The consumer will call EnqueueMessageAsync which signals the run loop
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
@@ -96,13 +95,6 @@ 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);
@@ -137,6 +129,9 @@ 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)
@@ -164,8 +164,10 @@ public class AgentFrameworkResponseHandlerTelemetryTests
private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null)
{
var request = agentKey is null
? new CreateResponse { Model = "test" }
: new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) };
? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test")
: AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference(agentKey));
request.Input = BinaryData.FromObjectAsJson(new[]
{
@@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -72,7 +72,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("my-agent"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -107,7 +109,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -156,7 +158,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "my-agent" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -193,7 +195,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
var metadata = new Metadata();
metadata.AdditionalProperties["entity_id"] = "entity-agent";
request.Metadata = metadata;
@@ -233,7 +235,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("nonexistent-agent"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -268,7 +272,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("missing-agent"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -302,7 +308,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -336,7 +342,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -381,7 +387,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -429,7 +435,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -472,7 +478,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -511,11 +517,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse
{
Model = "test",
Instructions = "You are a helpful assistant.",
};
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
instructions: "You are a helpful assistant.");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -553,7 +557,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -594,7 +598,9 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("agent-2"));
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -631,7 +637,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -668,7 +674,7 @@ public class AgentFrameworkResponseHandlerTests
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = BinaryData.FromObjectAsJson(new[]
{
new { type = "message", id = "msg_1", status = "completed", role = "user",
@@ -1,329 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
#pragma warning disable OPENAI001
#pragma warning disable AAIP001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Unit tests for the <see cref="FoundryToolbox"/> class.
/// </summary>
public class FoundryToolboxTests
{
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
#region Parameter validation tests
[Fact]
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: null!,
name: "test-toolbox"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
{
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
projectEndpoint: s_testEndpoint,
credential: new FakeAuthenticationTokenProvider(),
name: name!));
}
[Fact]
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
FoundryToolbox.GetToolsAsync(
projectEndpoint: null!,
credential: new FakeAuthenticationTokenProvider(),
name: "test-toolbox"));
}
[Fact]
public void ToAITools_NullToolboxVersion_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
FoundryToolbox.ToAITools(null!));
}
#endregion
#region ToAITools conversion tests
[Fact]
public void ToAITools_EmptyTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "empty-toolbox",
version: "v1",
description: "Empty",
createdAt: DateTimeOffset.UtcNow,
tools: Array.Empty<ProjectsAgentTool>(),
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_NullTools_ReturnsEmptyList()
{
var version = ProjectsAgentsModelFactory.ToolboxVersion(
metadata: null,
id: "ver-1",
name: "null-tools-toolbox",
version: "v1",
description: "Null tools",
createdAt: DateTimeOffset.UtcNow,
tools: null,
policies: null);
var tools = version.ToAITools();
Assert.Empty(tools);
}
[Fact]
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
{
var json = TestDataUtil.GetToolboxVersionResponseJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
{
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
var tools = version.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
[Fact]
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
{
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
Assert.IsAssignableFrom<AITool>(aiTool);
}
[Fact]
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
{
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
Assert.NotNull(aiTool);
}
#endregion
#region Integration tests with mock HTTP
[Fact]
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((request) =>
{
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
Assert.Single(result.Tools);
}
[Fact]
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
{
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
var callCount = 0;
using var httpHandler = new HttpHandlerAssert((request) =>
{
callCount++;
var path = request.RequestUri!.PathAndQuery;
if (!path.Contains("/versions/"))
{
Assert.Contains("/toolboxes/research_tools", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
};
}
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: null,
clientOptions: clientOptions,
cancellationToken: default);
Assert.Equal(2, callCount);
Assert.Equal("research_tools", result.Name);
Assert.Equal("v5", result.Version);
}
[Fact]
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
{
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
await Assert.ThrowsAsync<ClientResultException>(() =>
FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"nonexistent-toolbox",
version: "v1",
clientOptions: clientOptions,
cancellationToken: default));
}
[Fact]
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
var result = await FoundryToolbox.GetToolboxVersionAsync(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
"research_tools",
version: "v5",
clientOptions: clientOptions,
cancellationToken: default);
var tools = result.ToAITools();
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
#region AIProjectClient extension tests
[Fact]
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
{
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
using var httpHandler = new HttpHandlerAssert((_) =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(httpHandler);
#pragma warning restore CA5399
var clientOptions = new AIProjectClientOptions();
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
Assert.Single(tools);
Assert.IsAssignableFrom<AITool>(tools[0]);
}
#endregion
}
@@ -2,6 +2,7 @@
using System;
using System.Linq;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Foundry.Hosting;
using Microsoft.Extensions.AI;
@@ -145,7 +146,11 @@ public class InputConverterTests
[Fact]
public void ConvertToChatOptions_SetsTemperatureAndTopP()
{
var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
temperature: 0.7,
topP: 0.9,
maxOutputTokens: 1000,
model: "gpt-4o");
var options = InputConverter.ConvertToChatOptions(request);
@@ -206,9 +211,9 @@ public class InputConverterTests
}
[Fact]
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage()
{
var funcOutput = new OutputItemFunctionToolCallOutput(
var funcOutput = new FunctionToolCallOutputResource(
callId: "call_def",
output: BinaryData.FromString("result data"));
@@ -224,7 +229,8 @@ public class InputConverterTests
[Fact]
public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull()
{
var reasoning = new OutputItemReasoningItem("reason_001", []);
var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem(
id: "reason_001");
var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]);
@@ -655,7 +661,7 @@ public class InputConverterTests
[Fact]
public void ConvertToChatOptions_ModelId_NotSetFromRequest()
{
var request = new CreateResponse { Model = "my-model" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model");
var options = InputConverter.ConvertToChatOptions(request);
@@ -20,7 +20,7 @@ public class OutputConverterTests
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
@@ -160,7 +160,9 @@ public class WorkflowIntegrationTests
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(
model: "test",
agentReference: new AgentReference("my-workflow"));
request.Input = CreateUserInput("Test keyed workflow");
var mockContext = CreateMockContext();
@@ -361,7 +363,7 @@ public class WorkflowIntegrationTests
var sp = services.BuildServiceProvider();
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
var request = new CreateResponse { Model = "test" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test");
request.Input = CreateUserInput(userMessage);
var mockContext = CreateMockContext();
@@ -391,7 +393,7 @@ public class WorkflowIntegrationTests
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
{
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
var request = new CreateResponse { Model = "test-model" };
var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model");
var stream = new ResponseEventStream(mockContext.Object, request);
return (stream, mockContext);
}
@@ -10,7 +10,7 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
@@ -34,7 +34,7 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
</ItemGroup>
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<!-- Evaluation tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
@@ -50,15 +50,6 @@
<None Update="TestData\OpenAIDefaultResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxRecordResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionResponse.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,5 +0,0 @@
{
"id": "tbx-123",
"name": "research_tools",
"default_version": "v5"
}
@@ -1,11 +0,0 @@
{
"metadata": {},
"id": "tbv-research_tools-v5",
"name": "research_tools",
"version": "v5",
"description": "Example research toolbox",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter" }
]
}
@@ -1,11 +0,0 @@
{
"metadata": {},
"id": "tbv-dirty-v1",
"name": "dirty_toolbox",
"version": "v1",
"description": "Toolbox with decoration fields on tools",
"created_at": 1775779200,
"tools": [
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
]
}
@@ -14,9 +14,6 @@ internal static class TestDataUtil
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
@@ -165,19 +162,4 @@ internal static class TestDataUtil
}
return json;
}
/// <summary>
/// Gets the toolbox record response JSON.
/// </summary>
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
/// <summary>
/// Gets the toolbox version response JSON.
/// </summary>
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
/// <summary>
/// Gets the toolbox version response JSON with decoration fields on tools.
/// </summary>
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
}
@@ -586,457 +586,6 @@ public sealed class A2AAgentHandlerTests
#pragma warning restore MEAI001
/// <summary>
/// Verifies that in streaming mode, each update from RunStreamingAsync produces a message event.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_EnqueuesMessageForEachUpdateAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1" },
new AgentResponseUpdate(ChatRole.Assistant, "chunk 2") { ResponseId = "r2" }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.Equal(2, events.Messages.Count);
Assert.Equal("chunk 1", events.Messages[0].Parts![0].Text);
Assert.Equal("chunk 2", events.Messages[1].Parts![0].Text);
}
/// <summary>
/// Verifies that in streaming mode, when metadata is present, options with AdditionalProperties
/// are passed to RunStreamingAsync.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditionalPropertiesAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
options => capturedOptions = options));
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
Metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonSerializer.SerializeToElement("value1")
}
});
// Assert
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
}
/// <summary>
/// Verifies that in streaming mode, when metadata is null, null options are passed to RunStreamingAsync.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
bool optionsCaptured = false;
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
options => { capturedOptions = options; optionsCaptured = true; }));
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.True(optionsCaptured);
Assert.Null(capturedOptions);
}
/// <summary>
/// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
{
// Arrange
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]));
// Act & Assert
var eventQueue = new AgentEventQueue();
await Assert.ThrowsAsync<NotSupportedException>(() =>
handler.ExecuteAsync(
new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message
{
MessageId = "test-id",
Role = Role.User,
Parts = [new Part { Text = "Hello" }],
ReferenceTaskIds = ["other-task-id"]
}
},
eventQueue,
CancellationToken.None));
}
/// <summary>
/// Verifies that in streaming mode, when ContextId is null, a new one is generated.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WhenContextIdIsNull_GeneratesContextIdAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
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 in streaming mode, the provided ContextId is used in the response.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_UsesProvidedContextIdAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "my-streaming-ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Equal("my-streaming-ctx", message.ContextId);
}
/// <summary>
/// Verifies that in streaming mode, when Message is null, the handler succeeds with empty messages.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = null!
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Equal("ctx", message.ContextId);
}
/// <summary>
/// Verifies that in streaming mode, the ResponseId from the update is used as the MessageId in the response.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_ResponseIdIsUsedAsMessageIdAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "resp-42" }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.Equal("resp-42", message.MessageId);
}
/// <summary>
/// Verifies that in streaming mode, when ResponseId is null, a MessageId is still generated.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WhenResponseIdIsNull_GeneratesMessageIdAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = null }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Message message = Assert.Single(events.Messages);
Assert.NotNull(message.MessageId);
Assert.NotEmpty(message.MessageId);
}
/// <summary>
/// Verifies that in streaming mode, when the update has AdditionalProperties, the message has metadata.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WithResponseAdditionalProperties_ReturnsMessageWithMetadataAsync()
{
// Arrange
AdditionalPropertiesDictionary additionalProps = new()
{
["streamKey"] = "streamValue"
};
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = additionalProps }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
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.True(message.Metadata.ContainsKey("streamKey"));
}
/// <summary>
/// Verifies that in streaming mode, when the update has null AdditionalProperties, the message has null metadata.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
{
// Arrange
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = null }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
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 in streaming mode, the session is saved after all updates are processed.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync()
{
// 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);
AgentResponseUpdate[] updates =
[
new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }
];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
// Act
await InvokeExecuteAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx-stream",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert - verify session was saved
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-stream"),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that in streaming mode, when RunStreamingAsync yields no updates,
/// no messages are enqueued and the session is still saved.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync()
{
// 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);
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]), agentSessionStore: mockSessionStore.Object);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
{
StreamingResponse = true,
TaskId = "",
ContextId = "ctx",
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
Assert.Empty(events.Messages);
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx"),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that the CancellationToken is propagated to RunStreamingAsync in the streaming path.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsync()
{
// Arrange
CancellationToken capturedToken = default;
using var cts = new CancellationTokenSource();
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<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
(_, _, _, ct) => capturedToken = ct)
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
A2AAgentHandler handler = CreateHandler(agentMock);
// Act
var eventQueue = new AgentEventQueue();
await handler.ExecuteAsync(
new RequestContext
{
TaskId = "",
ContextId = "ctx",
StreamingResponse = true,
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 when no session store is provided, the handler uses InMemoryAgentSessionStore
/// and can execute successfully.
@@ -1272,308 +821,6 @@ public sealed class A2AAgentHandlerTests
Assert.True(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
/// CancellationToken.None even when RunAsync throws an exception.
/// </summary>
[Fact]
public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
{
// 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);
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>())
.ThrowsAsync(new InvalidOperationException("Agent failed"));
using var cts = new CancellationTokenSource();
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
// Act
var eventQueue = new AgentEventQueue();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
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));
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx"),
It.IsAny<AgentSession>(),
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
Times.Once);
}
/// <summary>
/// Verifies that in the streaming path, SaveSessionAsync is called with
/// CancellationToken.None even when RunStreamingAsync throws an exception.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync()
{
// 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);
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<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() => ToThrowingAsyncEnumerableAsync(new InvalidOperationException("Stream failed")));
using var cts = new CancellationTokenSource();
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
// Act
var eventQueue = new AgentEventQueue();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.ExecuteAsync(
new RequestContext
{
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
},
eventQueue,
cts.Token));
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-stream"),
It.IsAny<AgentSession>(),
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
Times.Once);
}
/// <summary>
/// Verifies that on the continuation path, SaveSessionAsync is called with
/// CancellationToken.None even when RunAsync throws an exception.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync()
{
// 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);
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>())
.ThrowsAsync(new InvalidOperationException("Agent failed"));
using var cts = new CancellationTokenSource();
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object);
// Act
var eventQueue = new AgentEventQueue();
var events = new EventCollector();
var readerTask = ReadEventsAsync(eventQueue, events);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.ExecuteAsync(
new RequestContext
{
StreamingResponse = false,
TaskId = "task-1", ContextId = "ctx-cont",
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
},
eventQueue,
cts.Token));
eventQueue.Complete(null);
await readerTask;
// Assert - SaveSessionAsync was called with CancellationToken.None despite the exception
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-cont"),
It.IsAny<AgentSession>(),
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
Times.Once);
}
/// <summary>
/// Verifies that in the non-streaming path, SaveSessionAsync is called with
/// CancellationToken.None rather than the caller's cancellation token.
/// </summary>
[Fact]
public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync()
{
// 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);
using var cts = new CancellationTokenSource();
// 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 - SaveSessionAsync was called with CancellationToken.None, not the caller's token
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx"),
It.IsAny<AgentSession>(),
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
Times.Once);
}
/// <summary>
/// Verifies that in the streaming path, SaveSessionAsync is called with
/// CancellationToken.None rather than the caller's cancellation token.
/// </summary>
[Fact]
public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync()
{
// 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);
AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }];
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object);
using var cts = new CancellationTokenSource();
// Act
var eventQueue = new AgentEventQueue();
await handler.ExecuteAsync(
new RequestContext
{
TaskId = "", ContextId = "ctx-stream", StreamingResponse = true,
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
},
eventQueue,
cts.Token);
eventQueue.Complete(null);
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-stream"),
It.IsAny<AgentSession>(),
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
Times.Once);
}
/// <summary>
/// Verifies that on the continuation path, SaveSessionAsync is called with
/// CancellationToken.None rather than the caller's cancellation token.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync()
{
// 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, "Done!")]);
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
using var cts = new CancellationTokenSource();
// Act
var eventQueue = new AgentEventQueue();
var events = new EventCollector();
var readerTask = ReadEventsAsync(eventQueue, events);
await handler.ExecuteAsync(
new RequestContext
{
StreamingResponse = false,
TaskId = "task-1", ContextId = "ctx-cont",
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
},
eventQueue,
cts.Token);
eventQueue.Complete(null);
await readerTask;
// Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-cont"),
It.IsAny<AgentSession>(),
It.Is<CancellationToken>(ct => ct == CancellationToken.None)),
Times.Once);
}
private static A2AAgentHandler CreateHandler(
Mock<AIAgent> agentMock,
AgentRunMode? runMode = null,
@@ -1658,68 +905,6 @@ public sealed class A2AAgentHandlerTests
return agentMock;
}
private static Mock<AIAgent> CreateStreamingAgentMock(IEnumerable<AgentResponseUpdate> updates)
{
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<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() => ToAsyncEnumerableAsync(updates));
return agentMock;
}
private static Mock<AIAgent> CreateStreamingAgentMockWithOptionsCapture(
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<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
(_, _, options, _) => optionsCallback(options))
.Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }]));
return agentMock;
}
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> items)
{
await Task.Yield();
foreach (var item in items)
{
yield return item;
}
}
private static async IAsyncEnumerable<AgentResponseUpdate> ToThrowingAsyncEnumerableAsync(Exception exception)
{
await Task.Yield();
throw exception;
#pragma warning disable CS0162 // Unreachable code detected - yield is required for async iterator
yield break;
#pragma warning restore CS0162
}
private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
{
var eventQueue = new AgentEventQueue();
@@ -147,67 +147,4 @@ public class MessageConverterTests
Assert.Equal("First message", result[0].Text);
Assert.Equal("Second message", result[1].Text);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithNoContents_ReturnsEmptyList()
{
// Arrange
var update = new AgentResponseUpdate();
// Act
var result = update.ToParts();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithTextContent_ReturnsTextPart()
{
// Arrange
var update = new AgentResponseUpdate(ChatRole.Assistant, "Hello from streaming!");
// Act
var result = update.ToParts();
// Assert
Assert.Single(result);
Assert.Equal("Hello from streaming!", result[0].Text);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithMultipleContents_ReturnsAllParts()
{
// Arrange
var update = new AgentResponseUpdate(ChatRole.Assistant, [
new TextContent("First chunk"),
new TextContent("Second chunk")
]);
// Act
var result = update.ToParts();
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("First chunk", result[0].Text);
Assert.Equal("Second chunk", result[1].Text);
}
[Fact]
public void ToParts_AgentResponseUpdate_WithUnsupportedContent_FiltersOutNulls()
{
// Arrange - FunctionCallContent maps to null Part since it's not a supported A2A content type
var update = new AgentResponseUpdate(ChatRole.Assistant, [
new TextContent("Supported text"),
new FunctionCallContent("call-1", "myFunction")
]);
// Act
var result = update.ToParts();
// Assert - only the text part should be returned
Assert.Single(result);
Assert.Equal("Supported text", result[0].Text);
}
}
@@ -269,29 +269,6 @@ 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)]
+1 -41
View File
@@ -7,44 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.2.0] - 2026-04-24
### Added
- **agent-framework-core**: Add functional workflow API ([#4238](https://github.com/microsoft/agent-framework/pull/4238))
- **agent-framework-core**, **agent-framework-github-copilot**: Add OpenTelemetry integration for `GitHubCopilotAgent` ([#5142](https://github.com/microsoft/agent-framework/pull/5142))
- **agent-framework-a2a**: Add Agent Framework to A2A bridge support ([#2403](https://github.com/microsoft/agent-framework/pull/2403))
- **agent-framework-foundry**: Surface `oauth_consent_request` events from Responses API in Foundry clients ([#5070](https://github.com/microsoft/agent-framework/pull/5070))
### Changed
- **agent-framework-core**, **agent-framework-foundry**: Update `FoundryAgent` for hosted agent sessions ([#5447](https://github.com/microsoft/agent-framework/pull/5447))
- **agent-framework-foundry-hosting**: Upgrade hosting server dependency and add more type support ([#5459](https://github.com/microsoft/agent-framework/pull/5459))
### Fixed
- **agent-framework-ag-ui**: Fix reasoning role and multimodal media parsing to follow specification ([#5389](https://github.com/microsoft/agent-framework/pull/5389))
- **agent-framework-foundry**: Stop emitting `[TOOLBOXES]` warning for every `FoundryChatClient` call ([#5440](https://github.com/microsoft/agent-framework/pull/5440))
- **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**: Fix user agent prefix ([#5455](https://github.com/microsoft/agent-framework/pull/5455))
## [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
@@ -977,9 +939,7 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...HEAD
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD
[1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0
[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1
[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0
+4 -32
View File
@@ -4,48 +4,20 @@ Agent-to-Agent (A2A) protocol support for inter-agent communication.
## Main Classes
- **`A2AAgent`** - Client to connect to remote A2A-compliant agents.
- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol.
- **`A2AAgent`** - Agent wrapper that exposes an agent via the A2A protocol
## Usage
### A2AAgent (Client)
```python
from agent_framework.a2a import A2AAgent
# Connect to a remote A2A agent
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
response = await a2a_agent.run("Hello!")
```
### A2AExecutor (Server/Bridge)
```python
from agent_framework.a2a import A2AExecutor
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
# Create an A2A executor for your agent
executor = A2AExecutor(agent=my_agent)
# Set up the request handler and server application
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
)
app = A2AStarletteApplication(
agent_card=my_agent_card,
http_handler=request_handler,
).build()
a2a_agent = A2AAgent(agent=my_agent)
```
## Import Path
```python
from agent_framework.a2a import A2AAgent, A2AExecutor
from agent_framework.a2a import A2AAgent
# or directly:
from agent_framework_a2a import A2AAgent, A2AExecutor
from agent_framework_a2a import A2AAgent
```
-38
View File
@@ -10,49 +10,11 @@ pip install agent-framework-a2a --pre
The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services.
### A2AAgent (Client)
The `A2AAgent` class is a client that wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents.
```python
from agent_framework.a2a import A2AAgent
# Connect to a remote A2A agent
a2a_agent = A2AAgent(url="http://remote-agent/a2a")
response = await a2a_agent.run("Hello!")
```
### A2AExecutor (Hosting)
The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients.
```python
from agent_framework.a2a import A2AExecutor
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
# Create an A2A executor for your agent
executor = A2AExecutor(agent=my_agent)
# Set up the request handler and server application
request_handler = DefaultRequestHandler(
agent_executor=executor,
task_store=InMemoryTaskStore(),
)
app = A2AStarletteApplication(
agent_card=my_agent_card,
http_handler=request_handler,
).build()
```
### Basic Usage Example
See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate:
- Connecting to remote A2A agents
- Hosting local agents via A2A protocol
- Sending messages and receiving responses
- Handling different content types (text, files, data)
- Streaming responses and real-time interaction
@@ -2,7 +2,6 @@
import importlib.metadata
from ._a2a_executor import A2AExecutor
from ._agent import A2AAgent, A2AContinuationToken
try:
@@ -13,6 +12,5 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"A2AAgent",
"A2AContinuationToken",
"A2AExecutor",
"__version__",
]
@@ -1,275 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from asyncio import CancelledError
from collections.abc import Mapping
from functools import partial
from typing import Any
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart
from a2a.utils import new_task
from agent_framework import (
AgentResponseUpdate,
AgentSession,
Message,
SupportsAgentRun,
)
from typing_extensions import override
from agent_framework_a2a._utils import get_uri_data
logger = logging.getLogger("agent_framework.a2a")
class A2AExecutor(AgentExecutor):
"""Execute AI agents using the A2A (Agent-to-Agent) protocol.
The A2AExecutor bridges AI agents built with the agent_framework library and the A2A protocol,
enabling structured agent execution with event-driven communication. It handles execution
contexts, delegates history management to the agent's session, and converts agent
responses into A2A protocol events.
The executor supports executing an Agent or WorkflowAgent. It provides comprehensive
error handling with task status updates and supports various content types including text,
binary data, and URI-based content.
Example:
.. code-block:: python
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard
from agent_framework.a2a import A2AExecutor
from agent_framework.openai import OpenAIResponsesClient
public_agent_card = AgentCard(
name="Food Agent",
description="A simple agent that provides food-related information.",
url="http://localhost:9999/",
version="1.0.0",
defaultInputModes=["text"],
defaultOutputModes=["text"],
capabilities=AgentCapabilities(streaming=True),
skills=[],
)
# Create an agent
agent = OpenAIResponsesClient().as_agent(
name="Food Agent",
instructions="A simple agent that provides food-related information.",
)
# Set up the A2A server with the A2AExecutor enabled for streaming
# and passing custom keyword arguments to the agent's run method.
request_handler = DefaultRequestHandler(
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
task_store=InMemoryTaskStore(),
)
server = A2AStarletteApplication(
agent_card=public_agent_card,
http_handler=request_handler,
).build()
Args:
agent: The AI agent to execute.
stream: Whether to stream the agent response. Defaults to False.
run_kwargs: Additional keyword arguments to pass to the agent's run method.
"""
def __init__(self, agent: SupportsAgentRun, stream: bool = False, run_kwargs: Mapping[str, Any] | None = None):
"""Initialize the A2AExecutor with the specified agent.
Args:
agent: The AI agent or workflow to execute.
stream: Whether to stream the agent response. Defaults to False.
run_kwargs: Additional keyword arguments to pass to the agent's run method.
Cannot contain 'session' or 'stream' as these are managed by the executor.
Raises:
ValueError: If run_kwargs contains 'session' or 'stream'.
"""
super().__init__()
self._agent: SupportsAgentRun = agent
self._stream: bool = stream
if run_kwargs:
if "session" in run_kwargs:
raise ValueError("run_kwargs cannot contain 'session' as it is managed by the executor.")
if "stream" in run_kwargs:
raise ValueError("run_kwargs cannot contain 'stream' as it is managed by the executor.")
self._run_kwargs: Mapping[str, Any] = run_kwargs or {}
@override
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Cancel agent execution for the given request context.
Uses a TaskUpdater to send a cancellation event through the provided event queue.
Args:
context: The request context identifying the task to cancel.
event_queue: The event queue to publish the cancellation event to.
Raises:
ValueError: If context_id is not provided in the RequestContext.
"""
if context.context_id is None:
raise ValueError("Context ID must be provided in the RequestContext")
updater = TaskUpdater(
event_queue=event_queue,
task_id=context.task_id or "",
context_id=context.context_id,
)
await updater.cancel()
@override
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Execute the agent with the given context and event queue.
Orchestrates the agent execution process: sets up the agent session,
executes the agent, processes response messages, and handles errors with appropriate task status updates.
"""
if context.context_id is None:
raise ValueError("Context ID must be provided in the RequestContext")
if context.message is None:
raise ValueError("Message must be provided in the RequestContext")
query = context.get_user_input()
task = context.current_task
if not task:
task = new_task(context.message)
await event_queue.enqueue_event(task)
updater = TaskUpdater(event_queue, task.id, context.context_id)
await updater.submit()
try:
await updater.start_work()
session = self._agent.create_session(session_id=task.context_id)
if self._stream:
await self._run_stream(query, session, updater)
else:
await self._run(query, session, updater)
# Mark as complete
await updater.complete()
except CancelledError:
await updater.update_status(state=TaskState.canceled, final=True)
except Exception as e:
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
await updater.update_status(
state=TaskState.failed,
final=True,
message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]),
)
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
"""Run the agent in streaming mode and publish updates to the task updater."""
response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs)
streamed_artifact_ids: set[str] = set()
await (
response_stream.with_transform_hook(
partial(self.handle_events, updater=updater, streamed_artifact_ids=streamed_artifact_ids)
)
).get_final_response()
async def _run(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
"""Run the agent in non-streaming mode and publish messages to the task updater."""
response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs)
response_messages = response.messages
if not isinstance(response_messages, list):
response_messages = [response_messages]
for message in response_messages:
await self.handle_events(message, updater)
async def handle_events(
self, item: Message | AgentResponseUpdate, updater: TaskUpdater, streamed_artifact_ids: set[str] | None = None
) -> None:
"""Convert agent response items (Messages or Updates) to A2A protocol events.
Processes Message or AgentResponseUpdate objects and converts them into A2A protocol format.
Handles text, data, and URI content. USER role messages are skipped.
Users can override this method in a subclass to implement custom transformations
from their agent's output format to A2A protocol events.
Args:
item: The agent response item (Message or AgentResponseUpdate) to process.
updater: The task updater to publish events to.
streamed_artifact_ids: A set of artifact IDs that have already been streamed.
Used to prevent duplicate updates for the same artifact.
Example:
.. code-block:: python
class CustomA2AExecutor(A2AExecutor):
async def handle_events(
self,
item: Message | AgentResponseUpdate,
updater: TaskUpdater,
streamed_artifact_ids: set[str] | None = None,
) -> None:
# Custom logic to transform item contents
if item.role == "assistant" and item.contents:
parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))]
await updater.update_status(
state=TaskState.working,
message=updater.new_agent_message(parts=parts),
)
else:
await super().handle_events(item, updater)
"""
role = getattr(item, "role", None)
if role == "user":
# This is a user message, we can ignore it in the context of task updates
return
parts: list[Part] = []
metadata = getattr(item, "additional_properties", None)
# AgentResponseUpdate uses 'contents', Message uses 'contents'
contents = getattr(item, "contents", [])
for content in contents:
if content.type == "text" and content.text:
parts.append(Part(root=TextPart(text=content.text)))
elif content.type == "data" and content.uri:
base64_str = get_uri_data(content.uri)
parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type))))
elif content.type == "uri" and content.uri:
parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type))))
else:
# Silently skip unsupported content types
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
if parts:
if isinstance(item, AgentResponseUpdate):
# For streaming updates, we send TaskArtifactUpdateEvent via add_artifact
await updater.add_artifact(
parts=parts,
artifact_id=item.message_id,
metadata=metadata,
append=(
True
if streamed_artifact_ids is not None and item.message_id in (streamed_artifact_ids or set())
else None
),
)
if item.message_id and streamed_artifact_ids is not None:
streamed_artifact_ids.add(item.message_id)
else:
# For final messages, we send TaskStatusUpdateEvent with 'working' state
await updater.update_status(
state=TaskState.working,
message=updater.new_agent_message(parts=parts, metadata=metadata),
)
@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
import json
import re
import uuid
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, Final, Literal, TypeAlias, overload
@@ -48,7 +49,7 @@ from agent_framework.observability import AgentTelemetryLayer
__all__ = ["A2AAgent", "A2AContinuationToken"]
from agent_framework_a2a._utils import get_uri_data
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
class A2AContinuationToken(ContinuationToken):
@@ -77,6 +78,14 @@ A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpda
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
def _get_uri_data(uri: str) -> str:
match = URI_PATTERN.match(uri)
if not match:
raise ValueError(f"Invalid data URI format: {uri}")
return match.group("base64_data")
class A2AAgent(AgentTelemetryLayer, BaseAgent):
"""Agent2Agent (A2A) protocol implementation.
@@ -286,10 +295,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
else:
if not normalized_messages:
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
a2a_message = self._prepare_message_for_a2a(
normalized_messages[-1],
context_id=session.service_session_id if session else None,
)
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1])
a2a_stream = self.client.send_message(a2a_message)
provider_session = session
@@ -578,7 +584,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
return AgentResponse.from_updates(updates)
return AgentResponse(messages=[], response_id=task.id, raw_representation=task)
def _prepare_message_for_a2a(self, message: Message, *, context_id: str | None = None) -> A2AMessage:
def _prepare_message_for_a2a(self, message: Message) -> A2AMessage:
"""Prepare a Message for the A2A protocol.
Transforms Agent Framework Message objects into A2A protocol Messages by:
@@ -587,13 +593,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
- Converting file references (URI/data/hosted_file) to FilePart objects
- Preserving metadata and additional properties from the original message
- Setting the role to 'user' as framework messages are treated as user input
Args:
message: The framework Message to convert.
context_id: Optional fallback context identifier (e.g. derived from
``AgentSession.service_session_id``). When the *message* already
carries a ``context_id`` in its ``additional_properties`` that
value takes precedence; otherwise this fallback is used.
"""
parts: list[A2APart] = []
if not message.contents:
@@ -643,7 +642,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
A2APart(
root=FilePart(
file=FileWithBytes(
bytes=get_uri_data(content.uri),
bytes=_get_uri_data(content.uri),
mime_type=content.media_type,
),
metadata=content.additional_properties,
@@ -673,7 +672,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
role=A2ARole("user"),
parts=parts,
message_id=message.message_id or uuid.uuid4().hex,
context_id=message.additional_properties.get("context_id") or context_id,
context_id=message.additional_properties.get("context_id"),
metadata=metadata,
)
@@ -1,24 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import re
URI_PATTERN = re.compile(r"^data:(?P<media_type>[^;]+);base64,(?P<base64_data>[A-Za-z0-9+/=]+)$")
def get_uri_data(uri: str) -> str:
"""Extracts the base64-encoded data from a data URI.
Args:
uri: The data URI to parse.
Returns:
The base64-encoded data part of the URI.
Raises:
ValueError: If the URI format is invalid.
"""
match = URI_PATTERN.match(uri)
if not match:
raise ValueError(f"Invalid data URI format: {uri}")
return match.group("base64_data")
+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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
+5 -75
View File
@@ -35,7 +35,7 @@ from agent_framework.a2a import A2AAgent
from pytest import fixture, mark, raises
from agent_framework_a2a import A2AContinuationToken
from agent_framework_a2a._utils import get_uri_data
from agent_framework_a2a._agent import _get_uri_data # type: ignore
class MockA2AClient:
@@ -46,7 +46,6 @@ class MockA2AClient:
self.responses: list[Any] = []
self.resubscribe_responses: list[Any] = []
self.get_task_response: Task | None = None
self.last_message: Any = None
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
"""Add a mock Message response."""
@@ -112,7 +111,6 @@ class MockA2AClient:
async def send_message(self, message: Any) -> AsyncIterator[Any]:
"""Mock send_message method that yields responses."""
self.last_message = message
self.call_count += 1
# All queued responses are delivered as a single streaming batch per call.
@@ -353,18 +351,18 @@ def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
def test_get_uri_data_valid_uri() -> None:
"""Test get_uri_data with valid data URI."""
"""Test _get_uri_data with valid data URI."""
uri = "data:application/json;base64,eyJ0ZXN0IjoidmFsdWUifQ=="
result = get_uri_data(uri)
result = _get_uri_data(uri)
assert result == "eyJ0ZXN0IjoidmFsdWUifQ=="
def test_get_uri_data_invalid_uri() -> None:
"""Test get_uri_data with invalid URI format."""
"""Test _get_uri_data with invalid URI format."""
with raises(ValueError, match="Invalid data URI format"):
get_uri_data("not-a-valid-data-uri")
_get_uri_data("not-a-valid-data-uri")
def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
@@ -541,37 +539,6 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None:
assert result.metadata == {"trace_id": "trace-456"}
def test_prepare_message_for_a2a_uses_fallback_context_id() -> None:
"""Test that context_id kwarg is used when message has no context_id property."""
agent = A2AAgent(client=MagicMock(), http_client=None)
message = Message(
role="user",
contents=[Content.from_text(text="Hello")],
)
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
assert result.context_id == "session-ctx-1"
def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
"""Test that message.additional_properties context_id wins over the fallback."""
agent = A2AAgent(client=MagicMock(), http_client=None)
message = Message(
role="user",
contents=[Content.from_text(text="Hello")],
additional_properties={"context_id": "explicit-ctx"},
)
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
assert result.context_id == "explicit-ctx"
def test_parse_contents_from_a2a_with_data_part() -> None:
"""Test conversion of A2A DataPart."""
@@ -901,43 +868,6 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A
# endregion
# region Session context_id Integration Tests
@mark.asyncio
async def test_run_passes_session_service_session_id_as_context_id(mock_a2a_client: MockA2AClient) -> None:
"""Test that run() wires session.service_session_id to the A2A message context_id."""
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
mock_a2a_client.add_message_response("msg-ctx", "reply")
session = AgentSession(service_session_id="svc-session-42")
await agent.run("Hello", session=session)
assert mock_a2a_client.last_message is not None
assert mock_a2a_client.last_message.context_id == "svc-session-42"
@mark.asyncio
async def test_run_message_context_id_takes_precedence_over_session(mock_a2a_client: MockA2AClient) -> None:
"""Test that an explicit context_id on the message wins over session.service_session_id."""
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
mock_a2a_client.add_message_response("msg-ctx2", "reply")
session = AgentSession(service_session_id="svc-session-42")
message = Message(
role="user",
contents=[Content.from_text(text="Hello")],
additional_properties={"context_id": "explicit-ctx"},
)
await agent.run(messages=[message], session=session)
assert mock_a2a_client.last_message is not None
assert mock_a2a_client.last_message.context_id == "explicit-ctx"
# endregion
# region Context Provider Tests
@@ -1,910 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from asyncio import CancelledError
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from a2a.types import Task, TaskState, TextPart
from agent_framework import (
AgentResponseUpdate,
Content,
Message,
SupportsAgentRun,
)
from agent_framework._types import AgentResponse
from agent_framework.a2a import A2AExecutor
from pytest import fixture, raises
@fixture
def mock_agent() -> MagicMock:
"""Fixture that provides a mock SupportsAgentRun."""
agent = MagicMock(spec=SupportsAgentRun)
agent.run = AsyncMock()
return agent
@fixture
def mock_request_context() -> MagicMock:
"""Fixture that provides a mock RequestContext."""
request_context = MagicMock()
request_context.context_id = str(uuid4())
request_context.get_user_input = MagicMock(return_value="Test query")
request_context.current_task = None
request_context.message = None
return request_context
@fixture
def mock_event_queue() -> MagicMock:
"""Fixture that provides a mock EventQueue."""
queue = AsyncMock()
queue.enqueue_event = AsyncMock()
return queue
@fixture
def mock_task() -> Task:
"""Fixture that provides a mock Task."""
task = MagicMock(spec=Task)
task.id = str(uuid4())
task.context_id = str(uuid4())
task.state = TaskState.completed
return task
@fixture
def mock_task_updater() -> MagicMock:
"""Fixture that provides a mock TaskUpdater."""
updater = MagicMock()
updater.submit = AsyncMock()
updater.start_work = AsyncMock()
updater.complete = AsyncMock()
updater.update_status = AsyncMock()
updater.new_agent_message = MagicMock()
return updater
@fixture
def executor(mock_agent: MagicMock) -> A2AExecutor:
"""Fixture that provides an A2AExecutor."""
return A2AExecutor(agent=mock_agent)
class TestA2AExecutorInitialization:
"""Tests for A2AExecutor initialization."""
def test_initialization_with_agent_only(self, mock_agent: MagicMock) -> None:
"""Arrange: Create mock agent
Act: Initialize A2AExecutor with only agent
Assert: Executor is created with default values
"""
# Act
executor = A2AExecutor(agent=mock_agent)
# Assert
assert executor._agent is mock_agent
assert executor._stream is False
assert executor._run_kwargs == {}
def test_initialization_with_stream_and_kwargs(self, mock_agent: MagicMock) -> None:
"""Arrange: Create mock agent
Act: Initialize A2AExecutor with stream and run_kwargs
Assert: Executor is created with specified values
"""
# Arrange
run_kwargs = {"temperature": 0.5}
# Act
executor = A2AExecutor(agent=mock_agent, stream=True, run_kwargs=run_kwargs)
# Assert
assert executor._agent is mock_agent
assert executor._stream is True
assert executor._run_kwargs == run_kwargs
def test_initialization_with_invalid_run_kwargs(self, mock_agent: MagicMock) -> None:
"""Arrange: Create mock agent
Act: Initialize A2AExecutor with reserved keys in run_kwargs
Assert: ValueError is raised
"""
# Act & Assert
with raises(ValueError, match="run_kwargs cannot contain 'session'"):
A2AExecutor(agent=mock_agent, run_kwargs={"session": "something"})
with raises(ValueError, match="run_kwargs cannot contain 'stream'"):
A2AExecutor(agent=mock_agent, run_kwargs={"stream": True})
class TestA2AExecutorCancel:
"""Tests for the cancel method."""
async def test_cancel_method_completes(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create executor with dependencies
Act: Call cancel method
Assert: Method completes without raising error
"""
# Arrange
mock_request_context.task_id = "task-123"
# Act & Assert (should not raise)
await executor.cancel(mock_request_context, mock_event_queue) # type: ignore
async def test_cancel_handles_different_contexts(
self,
executor: A2AExecutor,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create executor with multiple request contexts
Act: Call cancel with different contexts
Assert: Each cancel completes successfully
"""
# Arrange
context1 = MagicMock()
context1.context_id = "ctx-1"
context1.task_id = "task-1"
context2 = MagicMock()
context2.context_id = "ctx-2"
context2.task_id = "task-2"
# Act & Assert
await executor.cancel(context1, mock_event_queue) # type: ignore
await executor.cancel(context2, mock_event_queue) # type: ignore
async def test_cancel_raises_error_when_context_id_missing(
self,
executor: A2AExecutor,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create context without context_id
Act: Call cancel method
Assert: ValueError is raised
"""
# Arrange
mock_context = MagicMock()
mock_context.context_id = None
# Act & Assert
with raises(ValueError) as excinfo:
await executor.cancel(mock_context, mock_event_queue) # type: ignore
# Assert
assert "Context ID" in str(excinfo.value)
class TestA2AExecutorExecute:
"""Tests for the execute method."""
async def test_execute_with_existing_task_succeeds(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with mocked dependencies and existing task
Act: Call execute method
Assert: Execution completes successfully
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Hello back")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_updater.submit.assert_called_once()
mock_updater.start_work.assert_called_once()
mock_updater.complete.assert_called_once()
executor._agent.create_session.assert_called_once()
executor._agent.run.assert_called_once()
async def test_execute_creates_task_when_not_exists(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create executor with request context without task
Act: Call execute method
Assert: New task is created and enqueued
"""
# Arrange
mock_message = MagicMock()
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = None
mock_request_context.message = mock_message
mock_request_context.context_id = "ctx-123"
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task:
mock_task = MagicMock(spec=Task)
mock_task.id = "task-new"
mock_task.context_id = "ctx-123"
mock_new_task.return_value = mock_task
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_new_task.assert_called_once()
mock_event_queue.enqueue_event.assert_called_once()
async def test_execute_raises_error_when_context_id_missing(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create context without context_id
Act: Call execute method
Assert: ValueError is raised
"""
# Arrange
mock_request_context.context_id = None
mock_request_context.message = MagicMock()
# Act & Assert
with raises(ValueError) as excinfo:
await executor.execute(mock_request_context, mock_event_queue)
# Assert
assert "Context ID" in str(excinfo.value)
async def test_execute_raises_error_when_message_missing(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
) -> None:
"""Arrange: Create context without message
Act: Call execute method
Assert: ValueError is raised
"""
# Arrange
mock_request_context.context_id = "ctx-123"
mock_request_context.message = None
# Act & Assert
with raises(ValueError) as excinfo:
await executor.execute(mock_request_context, mock_event_queue)
# Assert
assert "Message" in str(excinfo.value)
async def test_execute_handles_cancelled_error(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor that raises CancelledError
Act: Call execute method
Assert: Error is caught and task is marked as canceled
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
executor._agent.run = AsyncMock(side_effect=CancelledError())
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue) # type: ignore
# Assert
mock_updater.update_status.assert_called()
call_args_list = mock_updater.update_status.call_args_list
assert any(
call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list
)
async def test_execute_handles_generic_exception(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor that raises generic exception
Act: Call execute method
Assert: Error is caught and task is marked as failed
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
error_message = "Test error"
executor._agent.run = AsyncMock(side_effect=ValueError(error_message))
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="error_message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_updater.new_agent_message.assert_called_once()
args, _ = mock_updater.new_agent_message.call_args
parts = args[0]
assert len(parts) == 1
assert isinstance(parts[0].root, TextPart)
assert parts[0].root.text == error_message
call_args_list = mock_updater.update_status.call_args_list
assert any(
call[1].get("state") == TaskState.failed
and call[1].get("final") is True
and call[1].get("message") == "error_message_obj"
for call in call_args_list
)
async def test_execute_processes_multiple_response_messages(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor that returns multiple response messages
Act: Call execute method
Assert: All messages are processed through handle_events
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message1 = Message(role="assistant", contents=[Content.from_text(text="First")])
response_message2 = Message(role="assistant", contents=[Content.from_text(text="Second")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message1, response_message2]
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
# Mock handle_events
executor.handle_events = AsyncMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
assert executor.handle_events.call_count == 2
async def test_execute_passes_query_to_run(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with request
Act: Call execute method
Assert: Query text is passed to run method with default stream and kwargs
"""
# Arrange
query_text = "Hello agent"
mock_request_context.get_user_input = MagicMock(return_value=query_text)
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater.new_agent_message = MagicMock(return_value="message_obj")
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
executor._agent.run.assert_called_once_with(
query_text, session=executor._agent.create_session(), stream=False
)
async def test_execute_with_stream_enabled(
self,
mock_agent: MagicMock,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with stream=True
Act: Call execute method
Assert: _run_stream is called and passes stream=True to run
"""
# Arrange
executor = A2AExecutor(agent=mock_agent, stream=True)
query_text = "Hello agent"
mock_request_context.get_user_input = MagicMock(return_value=query_text)
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
mock_response_stream = MagicMock()
mock_response_stream.with_transform_hook = MagicMock(return_value=mock_response_stream)
mock_response_stream.get_final_response = AsyncMock()
mock_agent.run = MagicMock(return_value=mock_response_stream)
mock_agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_agent.run.assert_called_once_with(query_text, session=mock_agent.create_session(), stream=True)
mock_response_stream.with_transform_hook.assert_called_once()
mock_response_stream.get_final_response.assert_called_once()
async def test_execute_with_run_kwargs(
self,
mock_agent: MagicMock,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with run_kwargs
Act: Call execute method
Assert: run_kwargs are passed to run method
"""
# Arrange
run_kwargs = {"temperature": 0.5, "max_tokens": 100}
executor = A2AExecutor(agent=mock_agent, run_kwargs=run_kwargs)
query_text = "Hello agent"
mock_request_context.get_user_input = MagicMock(return_value=query_text)
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = [response_message]
mock_agent.run = AsyncMock(return_value=response)
mock_agent.create_session = MagicMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_agent.run.assert_called_once_with(
query_text, session=mock_agent.create_session(), stream=False, **run_kwargs
)
class TestA2AExecutorHandleEvents:
"""Tests for A2AExecutor.handle_events method."""
async def test_run_method_with_single_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test the private _run method with a single message (not a list)."""
# Arrange
query = "test query"
session = MagicMock()
response_message = Message(role="assistant", contents=[Content.from_text(text="Response")])
response = MagicMock(spec=AgentResponse)
response.messages = response_message # Not a list
executor._agent.run = AsyncMock(return_value=response)
executor.handle_events = AsyncMock()
# Act
await executor._run(query, session, mock_updater)
# Assert
executor.handle_events.assert_called_once_with(response_message, mock_updater)
@fixture
def mock_updater(self) -> MagicMock:
"""Create a mock execution context."""
updater = MagicMock()
updater.update_status = AsyncMock()
updater.new_agent_message = MagicMock(return_value="mock_message")
return updater
async def test_ignore_user_messages(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that messages from USER role are ignored."""
# Arrange
message = Message(
contents=[Content.from_text(text="User input")],
role="user",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_not_called()
async def test_ignore_messages_with_no_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that messages with no contents are ignored."""
# Arrange
message = Message(
contents=[],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_not_called()
async def test_handle_text_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with text content."""
# Arrange
text = "Hello, this is a test message"
message = Message(
contents=[Content.from_text(text=text)],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
assert mock_updater.new_agent_message.called
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with multiple text contents."""
# Arrange
message = Message(
contents=[
Content.from_text(text="First message"),
Content.from_text(text="Second message"),
],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
assert mock_updater.new_agent_message.called
async def test_handle_data_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with data content."""
# Arrange
data = b"test file data"
message = Message(
contents=[Content.from_data(data=data, media_type="application/octet-stream")],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with URI content."""
# Arrange
uri = "https://example.com/file.pdf"
message = Message(
contents=[Content.from_uri(uri=uri, media_type="application/pdf")],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with mixed content types."""
# Arrange
data = b"file data"
message = Message(
contents=[
Content.from_text(text="Processing file..."),
Content.from_data(data=data, media_type="application/octet-stream"),
Content.from_uri(uri="https://example.com/reference.pdf", media_type="application/pdf"),
],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
call_args = mock_updater.update_status.call_args
assert call_args.kwargs["state"] == TaskState.working
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with additional properties metadata."""
# Arrange
additional_props = {"custom_field": "custom_value", "priority": "high"}
message = Message(
contents=[Content.from_text(text="Test message")],
role="assistant",
additional_properties=additional_props,
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
mock_updater.new_agent_message.assert_called_once()
call_args = mock_updater.new_agent_message.call_args
assert call_args.kwargs["metadata"] == additional_props
async def test_handle_with_no_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages without additional properties."""
# Arrange
message = Message(
contents=[Content.from_text(text="Test message")],
role="assistant",
additional_properties=None,
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.update_status.assert_called_once()
mock_updater.new_agent_message.assert_called_once()
call_args = mock_updater.new_agent_message.call_args
assert call_args.kwargs["metadata"] == {}
async def test_parts_list_passed_to_new_agent_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that parts list is correctly passed to new_agent_message."""
# Arrange
message = Message(
contents=[
Content.from_text(text="Message 1"),
Content.from_text(text="Message 2"),
],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
mock_updater.new_agent_message.assert_called_once()
call_kwargs = mock_updater.new_agent_message.call_args.kwargs
assert "parts" in call_kwargs
parts_list = call_kwargs["parts"]
assert len(parts_list) == 2
async def test_task_state_always_working(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test that task state is always set to working."""
# Arrange
message = Message(
contents=[Content.from_text(text="Any message")],
role="assistant",
)
# Act
await executor.handle_events(message, mock_updater)
# Assert
call_kwargs = mock_updater.update_status.call_args.kwargs
assert call_kwargs["state"] == TaskState.working
async def test_handle_agent_response_update_no_streamed_set(
self, executor: A2AExecutor, mock_updater: MagicMock
) -> None:
"""Test handling AgentResponseUpdate (streaming) without a tracking set."""
# Arrange
update = AgentResponseUpdate(
contents=[Content.from_text(text="Streaming chunk")],
role="assistant",
message_id="msg-1",
)
mock_updater.add_artifact = AsyncMock()
# Act
await executor.handle_events(update, mock_updater)
# Assert
mock_updater.add_artifact.assert_called_once()
call_kwargs = mock_updater.add_artifact.call_args.kwargs
assert call_kwargs["artifact_id"] == "msg-1"
assert call_kwargs["append"] is None
async def test_handle_agent_response_update_first_time(
self, executor: A2AExecutor, mock_updater: MagicMock
) -> None:
"""Test handling AgentResponseUpdate (streaming) for the first time with a tracking set."""
# Arrange
update = AgentResponseUpdate(
contents=[Content.from_text(text="Streaming chunk")],
role="assistant",
message_id="msg-1",
)
mock_updater.add_artifact = AsyncMock()
streamed_artifact_ids = set()
# Act
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
# Assert
mock_updater.add_artifact.assert_called_once()
call_kwargs = mock_updater.add_artifact.call_args.kwargs
assert call_kwargs["append"] is None
assert "msg-1" in streamed_artifact_ids
async def test_handle_agent_response_update_subsequent_time(
self, executor: A2AExecutor, mock_updater: MagicMock
) -> None:
"""Test handling AgentResponseUpdate (streaming) for subsequent times with a tracking set."""
# Arrange
update = AgentResponseUpdate(
contents=[Content.from_text(text="Next chunk")],
role="assistant",
message_id="msg-1",
)
mock_updater.add_artifact = AsyncMock()
streamed_artifact_ids = {"msg-1"}
# Act
await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids)
# Assert
mock_updater.add_artifact.assert_called_once()
call_kwargs = mock_updater.add_artifact.call_args.kwargs
assert call_kwargs["append"] is True
async def test_handle_unsupported_content_type(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
"""Test handling messages with unsupported content types."""
# Arrange
message = Message(
contents=[Content(type="unknown", text="Some text")],
role="assistant",
)
# Act
with patch("agent_framework_a2a._a2a_executor.logger") as mock_logger:
await executor.handle_events(message, mock_updater)
# Assert
mock_logger.warning.assert_called_once()
mock_updater.update_status.assert_not_called()
class TestA2AExecutorIntegration:
"""Integration tests for A2AExecutor."""
async def test_full_execution_flow_with_responses(
self,
executor: A2AExecutor,
mock_request_context: MagicMock,
mock_event_queue: MagicMock,
mock_task: Task,
) -> None:
"""Arrange: Create executor with all mocked dependencies
Act: Execute full flow from request to completion
Assert: All components interact correctly
"""
# Arrange
mock_request_context.get_user_input = MagicMock(return_value="Hello agent")
mock_request_context.current_task = mock_task
mock_request_context.context_id = "ctx-123"
mock_request_context.message = MagicMock()
response = MagicMock(spec=AgentResponse)
response_message = MagicMock(spec=Message)
response.messages = [response_message]
response_message.contents = [Content.from_text(text="Hello user")]
response_message.role = "assistant"
response_message.additional_properties = None
executor._agent.run = AsyncMock(return_value=response)
executor._agent.create_session = MagicMock()
executor.handle_events = AsyncMock()
with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class:
mock_updater = MagicMock()
mock_updater.submit = AsyncMock()
mock_updater.start_work = AsyncMock()
mock_updater.complete = AsyncMock()
mock_updater.update_status = AsyncMock()
mock_updater_class.return_value = mock_updater
# Act
await executor.execute(mock_request_context, mock_event_queue)
# Assert
mock_updater.submit.assert_called_once()
mock_updater.start_work.assert_called_once()
executor.handle_events.assert_called_once()
mock_updater.complete.assert_called_once()
-41
View File
@@ -1,41 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import pytest
from agent_framework_a2a._utils import get_uri_data
def test_get_uri_data_valid() -> None:
"""Test get_uri_data with valid data URIs."""
# Simple text/plain
uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ=="
# Image png
uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
assert get_uri_data(uri) == "iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
# Application octet-stream
uri = "data:application/octet-stream;base64,AQIDBA=="
assert get_uri_data(uri) == "AQIDBA=="
def test_get_uri_data_invalid_format() -> None:
"""Test get_uri_data with invalid URI formats."""
invalid_uris = [
"not-a-uri",
"http://example.com",
"data:text/plain;SGVsbG8sIFdvcmxkIQ==", # Missing base64 marker
"data:base64,SGVsbG8sIFdvcmxkIQ==", # Missing media type
"data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ==", # Extra parameters (current regex doesn't support)
"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== extra",
]
for uri in invalid_uris:
with pytest.raises(ValueError, match="Invalid data URI format"):
get_uri_data(uri)
def test_get_uri_data_empty() -> None:
"""Test get_uri_data with empty string."""
with pytest.raises(ValueError, match="Invalid data URI format"):
get_uri_data("")
@@ -790,9 +790,9 @@ async def run_agent_stream(
# Create session (with service session support)
if config.use_service_session:
supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId")
session = AgentSession(session_id=thread_id, service_session_id=supplied_thread_id)
session = AgentSession(service_session_id=supplied_thread_id)
else:
session = AgentSession(session_id=thread_id)
session = AgentSession()
# Inject metadata for AG-UI orchestration (Feature #2: Azure-safe truncation)
base_metadata: dict[str, Any] = {
@@ -263,21 +263,27 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
return unique_messages
def _extract_multimodal_source_fields(
part: dict[str, Any],
) -> tuple[str | None, str | None, str | None, str | None]:
"""Extract ``(url, data, binary_id, mime_type)`` from an AG-UI multimodal part.
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
"""Convert a multimodal media part into Agent Framework content."""
part_type = str(part.get("type", "")).lower()
source = part.get("source")
Handles both the current AG-UI spec (``source.value`` for base64 payloads) and the
legacy ``source.data`` field for backward compatibility. Returned values are the
raw extracted strings (or ``None`` when absent); callers apply their own defaults.
"""
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
mime_type = cast(
str | None,
part.get("mimeType")
or part.get("mime_type")
or {
"image": "image/*",
"audio": "audio/*",
"video": "video/*",
"document": "application/octet-stream",
"binary": "application/octet-stream",
}.get(part_type, "application/octet-stream"),
)
url = cast(str | None, part.get("url") or part.get("uri"))
data = cast(str | None, part.get("data"))
binary_id = cast(str | None, part.get("id"))
source = part.get("source")
if isinstance(source, dict):
source_dict = cast(dict[str, Any], source)
source_type = str(source_dict.get("type", "")).lower()
@@ -288,31 +294,14 @@ def _extract_multimodal_source_fields(
if source_type in {"url", "uri"}:
url = cast(str | None, source_dict.get("url") or source_dict.get("uri"))
elif source_type in {"base64", "data", "binary"}:
data = cast(str | None, source_dict.get("value") or source_dict.get("data"))
data = cast(str | None, source_dict.get("data"))
elif source_type in {"id", "file"}:
binary_id = cast(str | None, source_dict.get("id"))
else:
url = cast(str | None, source_dict.get("url") or source_dict.get("uri") or url)
data = cast(str | None, source_dict.get("value") or source_dict.get("data") or data)
data = cast(str | None, source_dict.get("data") or data)
binary_id = cast(str | None, source_dict.get("id") or binary_id)
return url, data, binary_id, mime_type
def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None:
"""Convert a multimodal media part into Agent Framework content."""
part_type = str(part.get("type", "")).lower()
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
if not mime_type:
mime_type = {
"image": "image/*",
"audio": "audio/*",
"video": "video/*",
"document": "application/octet-stream",
"binary": "application/octet-stream",
}.get(part_type, "application/octet-stream")
if isinstance(url, str) and url:
return Content.from_uri(uri=url, media_type=mime_type)
@@ -400,7 +389,30 @@ def _normalize_snapshot_content(content: Any) -> Any:
def _legacy_binary_part(part: dict[str, Any]) -> dict[str, Any]:
"""Convert draft/legacy multimodal parts to AG-UI snapshot binary shape."""
normalized: dict[str, Any] = {"type": "binary"}
url, data, binary_id, mime_type = _extract_multimodal_source_fields(part)
mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type"))
url = cast(str | None, part.get("url") or part.get("uri"))
data = cast(str | None, part.get("data"))
binary_id = cast(str | None, part.get("id"))
source = part.get("source")
if isinstance(source, dict):
source_part = cast(dict[str, Any], source)
source_mime = source_part.get("mimeType") or source_part.get("mime_type")
if isinstance(source_mime, str) and source_mime:
mime_type = source_mime
source_type = str(source_part.get("type", "")).lower()
if source_type in {"url", "uri"}:
url = cast(str | None, source_part.get("url") or source_part.get("uri"))
elif source_type in {"base64", "data", "binary"}:
data = cast(str | None, source_part.get("data"))
elif source_type in {"id", "file"}:
binary_id = cast(str | None, source_part.get("id"))
else:
url = cast(str | None, source_part.get("url") or source_part.get("uri") or url)
data = cast(str | None, source_part.get("data") or data)
binary_id = cast(str | None, source_part.get("id") or binary_id)
if isinstance(mime_type, str) and mime_type:
normalized["mimeType"] = mime_type
@@ -596,7 +596,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
events.extend(_close_reasoning_block(flow))
# Open new reasoning block.
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
flow.reasoning_message_id = message_id
if text:
@@ -613,7 +613,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis
else:
# No flow -- backward-compatible full sequence per call.
events.append(ReasoningStartEvent(message_id=message_id))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant"))
if text:
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260424"
version = "1.0.0b260421"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,8 +22,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"agent-framework-core>=1.1.0,<2",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
]
@@ -183,7 +183,6 @@ class StubAgent(SupportsAgentRun):
self.client = client or SimpleNamespace(function_invocation_configuration=None)
self.messages_received: list[Any] = []
self.tools_received: list[Any] | None = None
self.last_session: AgentSession | None = None
@overload
def run(
@@ -217,7 +216,6 @@ class StubAgent(SupportsAgentRun):
async def _stream() -> AsyncIterator[AgentResponseUpdate]:
self.messages_received = [] if messages is None else list(messages) # type: ignore[arg-type]
self.last_session = session
self.tools_received = kwargs.get("tools")
for update in self.updates:
yield update
@@ -536,77 +536,6 @@ def test_agui_snapshot_format_preserves_multimodal_content():
assert content_parts[1]["url"] == "https://example.com/image.png"
def test_agui_snapshot_format_reads_base64_value_field():
"""Snapshot normalization reads the spec 'value' field for base64 sources."""
payload = base64.b64encode(b"abc").decode("utf-8")
normalized = agui_messages_to_snapshot_format(
[
{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "value": payload, "mimeType": "image/png"},
},
],
}
]
)
binary_part = normalized[0]["content"][0]
assert binary_part["type"] == "binary"
assert binary_part["mimeType"] == "image/png"
assert binary_part["data"] == payload
def test_agui_snapshot_format_base64_value_preferred_over_data():
"""Snapshot normalization prefers 'value' when both 'value' and 'data' are set."""
value_payload = base64.b64encode(b"new-spec").decode("utf-8")
data_payload = base64.b64encode(b"legacy").decode("utf-8")
normalized = agui_messages_to_snapshot_format(
[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"value": value_payload,
"data": data_payload,
"mimeType": "image/png",
},
},
],
}
]
)
binary_part = normalized[0]["content"][0]
assert binary_part["data"] == value_payload
def test_agui_snapshot_format_base64_data_field_backward_compat():
"""Snapshot normalization still reads the legacy 'data' field when 'value' is absent."""
payload = base64.b64encode(b"legacy").decode("utf-8")
normalized = agui_messages_to_snapshot_format(
[
{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "data": payload, "mimeType": "image/png"},
},
],
}
]
)
binary_part = normalized[0]["content"][0]
assert binary_part["data"] == payload
def test_agui_with_tool_calls_to_agent_framework():
"""Assistant message with tool_calls is converted to FunctionCallContent."""
agui_msg = {
@@ -1831,67 +1760,3 @@ class TestReasoningRoundTrip:
assert "First answer" in texts
assert "Follow-up question" in texts
assert "Prior reasoning" not in texts
def test_parse_multimodal_media_part_base64_value_field():
"""Source with type='base64' reads data from the 'value' field per AG-UI spec."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part(
{"type": "image", "source": {"type": "base64", "value": "aGVsbG8=", "mimeType": "image/png"}}
)
assert result is not None
assert "aGVsbG8=" in result.uri
def test_parse_multimodal_media_part_data_source_value_field():
"""Source with type='data' reads data from the 'value' field per AG-UI spec."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part(
{"type": "image", "source": {"type": "data", "value": "aGVsbG8=", "mimeType": "image/png"}}
)
assert result is not None
assert "aGVsbG8=" in result.uri
def test_parse_multimodal_media_part_base64_data_field_backward_compat():
"""Source with type='base64' still supports deprecated 'data' field."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part(
{"type": "image", "source": {"type": "base64", "data": "aGVsbG8=", "mimeType": "image/png"}}
)
assert result is not None
assert "aGVsbG8=" in result.uri
def test_parse_multimodal_media_part_value_preferred_over_data():
"""When both 'value' and 'data' are present, 'value' takes precedence."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part(
{
"type": "image",
"source": {
"type": "base64",
"value": "dmFsdWU=",
"data": "ZGF0YQ==",
"mimeType": "image/png",
},
}
)
assert result is not None
# 'value' field content should be used (base64 of "value")
assert "dmFsdWU=" in result.uri
def test_parse_multimodal_media_part_unknown_source_value_fallback():
"""Unknown source type falls back to 'value' field before 'data' field."""
from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part
result = _parse_multimodal_media_part(
{"type": "image", "source": {"type": "custom", "value": "aGVsbG8=", "mimeType": "image/png"}}
)
assert result is not None
assert "aGVsbG8=" in result.uri
+1 -144
View File
@@ -1244,7 +1244,7 @@ class TestEmitTextReasoning:
assert events[0].message_id == "reason_1"
assert isinstance(events[1], ReasoningMessageStartEvent)
assert events[1].message_id == "reason_1"
assert events[1].role == "reasoning"
assert events[1].role == "assistant"
assert isinstance(events[2], ReasoningMessageContentEvent)
assert events[2].message_id == "reason_1"
assert events[2].delta == "The user is asking about weather, so I should call the weather tool."
@@ -1640,146 +1640,3 @@ class TestReasoningInSnapshot:
# close: MsgEnd(block2) + End(block2)
assert isinstance(close[0], ReasoningMessageEndEvent)
assert close[0].message_id == "block2"
class TestReasoningEventRole:
"""Tests that reasoning events use role='reasoning' per AG-UI spec."""
def test_reasoning_role_without_flow(self):
"""ReasoningMessageStartEvent uses role='reasoning' in non-flow mode."""
content = Content.from_text_reasoning(
id="reason_role_1",
text="Thinking about the question.",
)
events = _emit_text_reasoning(content)
msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)]
assert len(msg_starts) == 1
assert msg_starts[0].role == "reasoning"
def test_reasoning_role_with_flow(self):
"""ReasoningMessageStartEvent uses role='reasoning' in streaming flow mode."""
flow = FlowState()
content = Content.from_text_reasoning(
id="reason_role_2",
text="Reasoning in streaming mode.",
)
events = _emit_text_reasoning(content, flow)
msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)]
assert len(msg_starts) == 1
assert msg_starts[0].role == "reasoning"
async def test_session_id_matches_thread_id():
"""Session created by run_agent_stream uses the client thread_id as session_id."""
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub)
payload = {
"thread_id": "my-thread-123",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
assert stub.last_session.session_id == "my-thread-123"
async def test_session_id_matches_camel_case_thread_id():
"""Session uses threadId (camelCase) as session_id when snake_case is absent."""
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub)
payload = {
"threadId": "camel-thread-456",
"run_id": "run-2",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
assert stub.last_session.session_id == "camel-thread-456"
async def test_session_id_matches_thread_id_with_service_session():
"""Session uses thread_id as session_id even when use_service_session is enabled."""
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub, use_service_session=True)
payload = {
"thread_id": "service-thread-789",
"run_id": "run-3",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
assert stub.last_session.session_id == "service-thread-789"
assert stub.last_session.service_session_id == "service-thread-789"
async def test_session_id_generated_when_no_thread_id():
"""Session gets a generated UUID as session_id when no thread_id is provided."""
import uuid
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub)
payload = {
"run_id": "run-4",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
# Should be a valid UUID (auto-generated)
uuid.UUID(stub.last_session.session_id)
async def test_service_session_no_thread_id_generates_uuid():
"""With use_service_session=True and no thread_id, session_id is a UUID and service_session_id is None."""
import uuid
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub, use_service_session=True)
payload = {
"run_id": "run-5",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
# session_id should be a valid auto-generated UUID
uuid.UUID(stub.last_session.session_id)
# service_session_id should be None since no thread_id was supplied
assert stub.last_session.service_session_id is None
@@ -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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
super().__init__(
@@ -8,6 +8,7 @@ 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,
@@ -27,7 +28,6 @@ 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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_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": get_user_agent()}
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -3,8 +3,7 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import ChatMiddlewareLayer, FunctionInvocationLayer
from agent_framework._telemetry import get_user_agent
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_anthropic import (
@@ -62,7 +61,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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
@@ -86,7 +85,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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
@@ -131,7 +130,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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
@@ -153,5 +152,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": get_user_agent()},
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
@@ -14,6 +14,7 @@ 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,
@@ -24,7 +25,6 @@ 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=get_user_agent(),
user_agent=AGENT_FRAMEWORK_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=get_user_agent(),
user_agent=AGENT_FRAMEWORK_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=get_user_agent(),
user_agent=AGENT_FRAMEWORK_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=get_user_agent(),
user_agent=AGENT_FRAMEWORK_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=get_user_agent(),
user_agent=AGENT_FRAMEWORK_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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<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=get_user_agent(),
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
)
self._owns_client = True
@@ -10,10 +10,9 @@ import uuid
from collections.abc import Sequence
from typing import Any, ClassVar, TypedDict
from agent_framework import Message
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, 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
@@ -122,7 +121,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=get_user_agent(),
user_agent_suffix=AGENT_FRAMEWORK_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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
@@ -13,6 +13,7 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict
from uuid import uuid4
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
@@ -30,7 +31,6 @@ 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=get_user_agent()),
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
)
super().__init__(
@@ -11,6 +11,7 @@ from collections.abc import Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseEmbeddingClient,
Embedding,
EmbeddingGenerationOptions,
@@ -19,7 +20,6 @@ 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=get_user_agent()),
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -125,7 +125,6 @@ from ._telemetry import (
prepend_agent_framework_to_user_agent,
)
from ._tools import (
SKIP_PARSING,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
@@ -213,15 +212,6 @@ from ._workflows._executor import (
handler,
)
from ._workflows._function_executor import FunctionExecutor, executor
from ._workflows._functional import (
FunctionalWorkflow,
FunctionalWorkflowAgent,
RunContext,
StepWrapper,
get_run_context,
step,
workflow,
)
from ._workflows._request_info_mixin import response_handler
from ._workflows._runner import Runner
from ._workflows._runner_context import (
@@ -268,7 +258,6 @@ __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",
@@ -341,8 +330,6 @@ __all__ = [
"FunctionMiddleware",
"FunctionMiddlewareTypes",
"FunctionTool",
"FunctionalWorkflow",
"FunctionalWorkflowAgent",
"GeneratedEmbeddings",
"GraphConnectivityError",
"HistoryProvider",
@@ -365,7 +352,6 @@ __all__ = [
"ResponseStream",
"Role",
"RoleLiteral",
"RunContext",
"Runner",
"RunnerContext",
"SecretString",
@@ -378,7 +364,6 @@ __all__ = [
"SkillScriptRunner",
"SkillsProvider",
"SlidingWindowStrategy",
"StepWrapper",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
"SummarizationStrategy",
@@ -437,7 +422,6 @@ __all__ = [
"evaluator",
"executor",
"function_middleware",
"get_run_context",
"handler",
"included_messages",
"included_token_count",
@@ -453,7 +437,6 @@ __all__ = [
"register_state_type",
"resolve_agent_id",
"response_handler",
"step",
"tool",
"tool_call_args_match",
"tool_called_check",
@@ -462,5 +445,4 @@ __all__ = [
"validate_tool_mode",
"validate_tools",
"validate_workflow_graph",
"workflow",
]
@@ -48,7 +48,6 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
SKILLS = "SKILLS"
TOOLBOXES = "TOOLBOXES"
@@ -2,9 +2,11 @@
from __future__ import annotations
import contextlib
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
@@ -27,71 +29,34 @@ 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]
# 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
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
def _add_user_agent_prefix(prefix: str) -> None:
"""Permanently add a prefix to the user agent string.
@contextmanager
def user_agent_prefix(prefix: str) -> Generator[None]:
"""Context manager that adds a prefix to the user agent string for the current scope.
This is used by hosting layers to identify themselves in telemetry.
Once added, the prefix applies to all subsequent user agent strings.
This is useful for upstream layers that want to identify themselves in telemetry
for the duration of a request without permanently mutating global state.
Args:
prefix: The prefix to add (e.g. "foundry-hosting").
"""
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
if (env_value := os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)) is not None:
# Env var exists — trust its value and skip the fallback.
if env_value:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
_hosted_env_detected = True
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
current = _user_agent_prefixes.get()
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
try:
if importlib.util.find_spec("azure.ai.agentserver.core") is None:
return
except (ModuleNotFoundError, ValueError):
return
with contextlib.suppress(ImportError, AttributeError):
from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports]
if AgentConfig.from_env().is_hosted:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
_hosted_env_detected = True
yield
finally:
if token is not None:
_user_agent_prefixes.reset(token)
def get_user_agent() -> str:
"""Return the full user agent string including any registered prefixes."""
_detect_hosted_environment()
if not _user_agent_prefixes:
def _get_user_agent() -> str:
"""Return the full user agent string including any context-scoped prefixes."""
prefixes = _user_agent_prefixes.get()
if not prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -124,7 +89,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
+12 -88
View File
@@ -94,33 +94,6 @@ 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
@@ -306,7 +279,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]] | _SkipParsingSentinel | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the FunctionTool.
@@ -354,11 +327,9 @@ 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. 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``.
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``.
**kwargs: Additional keyword arguments.
"""
# Core attributes (formerly from BaseTool)
@@ -537,65 +508,31 @@ 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,
) -> 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:
) -> list[Content]:
"""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``
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.
if one was provided. Every result — text, rich media, or serialized objects —
is represented uniformly as Content items.
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:
``list[Content]`` by default. The raw function return value (``Any``) when
``skip_parsing=True`` (or the tool was constructed with
``result_parser=SKIP_PARSING``).
A list of Content items representing the tool output.
Raises:
TypeError: If arguments is not mapping-like or fails schema checks.
@@ -607,9 +544,7 @@ class FunctionTool(SerializationMixin):
from ._types import Content
from .observability import OBSERVABILITY_SETTINGS
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
parser = self.result_parser or FunctionTool.parse_result
parameter_names = set(self.parameters().get("properties", {}).keys())
direct_argument_kwargs = (
@@ -681,10 +616,6 @@ 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:
@@ -740,13 +671,6 @@ 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:
@@ -1143,7 +1067,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]] | _SkipParsingSentinel | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
) -> FunctionTool: ...
@@ -1159,7 +1083,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]] | _SkipParsingSentinel | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
@@ -1174,7 +1098,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]] | _SkipParsingSentinel | None = None,
result_parser: Callable[[Any], str | list[Content]] | 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.
@@ -120,7 +120,6 @@ WorkflowEventType = Literal[
"executor_invoked", # Executor handler was called (use .executor_id, .data)
"executor_completed", # Executor handler completed (use .executor_id, .data)
"executor_failed", # Executor handler raised error (use .executor_id, .details)
"executor_bypassed", # Executor skipped via cache hit during replay (use .executor_id, .data)
# Orchestration event types (use .data for typed payload)
"group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501
"handoff_sent", # Handoff routing events (use .data as HandoffSentEvent)
@@ -149,7 +148,6 @@ class WorkflowEvent(Generic[DataT]):
- `WorkflowEvent.executor_invoked(executor_id)` - executor handler called
- `WorkflowEvent.executor_completed(executor_id)` - executor handler completed
- `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed
- `WorkflowEvent.executor_bypassed(executor_id)` - executor skipped via cache hit
The generic parameter DataT represents the type of the event's data payload:
- Lifecycle events: `WorkflowEvent[None]` (data is None)
@@ -320,11 +318,6 @@ class WorkflowEvent(Generic[DataT]):
"""Create an 'executor_failed' event when an executor handler raises an error."""
return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details)
@classmethod
def executor_bypassed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create an 'executor_bypassed' event when a step is skipped via cache hit during replay."""
return cls("executor_bypassed", executor_id=executor_id, data=data)
# ==========================================================================
# Property for type-safe access
# ==========================================================================
File diff suppressed because it is too large Load Diff
@@ -340,10 +340,10 @@ class Workflow(DictConvertible):
# Emit explicit start/status events to the stream
with _framework_event_origin():
started = WorkflowEvent.started()
yield started # noqa: RUF070
yield started
with _framework_event_origin():
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
yield in_progress # noqa: RUF070
yield in_progress
# Reset context for a new run if supported
if reset_context:
@@ -388,7 +388,7 @@ class Workflow(DictConvertible):
emitted_in_progress_pending = True
with _framework_event_origin():
pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
yield pending_status # noqa: RUF070
yield pending_status
# Workflow runs until idle - emit final status based on whether requests are pending
if saw_request:
with _framework_event_origin():
@@ -409,10 +409,10 @@ class Workflow(DictConvertible):
details = WorkflowErrorDetails.from_exception(exc)
with _framework_event_origin():
failed_event = WorkflowEvent.failed(details)
yield failed_event # noqa: RUF070
yield failed_event
with _framework_event_origin():
failed_status = WorkflowEvent.status(WorkflowRunState.FAILED)
yield failed_status # noqa: RUF070
yield failed_status
span.add_event(
name=OtelAttr.WORKFLOW_ERROR,
attributes={
@@ -7,7 +7,6 @@ This module lazily re-exports objects from:
Supported classes:
- A2AAgent
- A2AExecutor
"""
import importlib
@@ -15,7 +14,7 @@ from typing import Any
IMPORT_PATH = "agent_framework_a2a"
PACKAGE_NAME = "agent-framework-a2a"
_IMPORTS = ["A2AAgent", "A2AExecutor"]
_IMPORTS = ["A2AAgent"]
def __getattr__(name: str) -> Any:
@@ -1,5 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_a2a import A2AAgent, A2AExecutor
from agent_framework_a2a import (
A2AAgent,
)
__all__ = ["A2AAgent", "A2AExecutor"]
__all__ = [
"A2AAgent",
]
@@ -14,7 +14,6 @@ from typing import Any
_IMPORTS: dict[str, tuple[str, str]] = {
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
"FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"),
"FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"),
@@ -9,7 +9,6 @@ Supported classes:
- GitHubCopilotAgent
- GitHubCopilotOptions
- GitHubCopilotSettings
- RawGitHubCopilotAgent
"""
import importlib
@@ -19,7 +18,6 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"GitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
"GitHubCopilotOptions": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
"GitHubCopilotSettings": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
"RawGitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"),
}
@@ -4,12 +4,10 @@ from agent_framework_github_copilot import (
GitHubCopilotAgent,
GitHubCopilotOptions,
GitHubCopilotSettings,
RawGitHubCopilotAgent,
)
__all__ = [
"GitHubCopilotAgent",
"GitHubCopilotOptions",
"GitHubCopilotSettings",
"RawGitHubCopilotAgent",
]
+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.2.0"
version = "1.1.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+44 -181
View File
@@ -1,21 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from unittest.mock import MagicMock, patch
from unittest.mock import 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 (
_FOUNDRY_HOSTING_ENV_VAR,
_HOSTED_USER_AGENT_PREFIX,
_add_user_agent_prefix,
_detect_hosted_environment,
)
from agent_framework._telemetry import user_agent_prefix
# region Test constants
@@ -90,7 +83,7 @@ def test_prepend_to_empty_headers():
def test_prepend_to_empty_dict():
"""Test prepending to empty headers dict."""
headers: dict[str, str] = {}
headers = {}
result = prepend_agent_framework_to_user_agent(headers)
assert "User-Agent" in result
@@ -106,184 +99,54 @@ def test_modifies_original_dict():
assert "User-Agent" in headers
# region Test _add_user_agent_prefix
# region Test user_agent_prefix context manager
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"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
_telemetry_mod._user_agent_prefixes.clear()
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"]
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_add_user_agent_prefix_ignores_empty():
"""Test that empty strings are not added as prefixes."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("")
# Prefix is removed after exiting the context
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
_telemetry_mod._user_agent_prefixes.clear()
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")
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_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
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
_telemetry_mod._user_agent_prefixes.clear()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
# 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
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
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
@@ -8,7 +8,6 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from pydantic import BaseModel
from agent_framework import (
SKIP_PARSING,
Content,
FunctionTool,
tool,
@@ -1301,165 +1300,4 @@ 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
@@ -529,12 +529,11 @@ class TestFunctionExecutor:
assert "@handler on instance methods" in str(exc_info.value)
async def test_async_staticmethod_detection_behavior(self):
"""Document the behavior of inspect.iscoroutinefunction with staticmethod descriptors.
"""Document the behavior of asyncio.iscoroutinefunction with staticmethod descriptors.
This test explains why the unwrapping is necessary when decorators are stacked.
"""
import asyncio
import inspect
# When @staticmethod is applied, it creates a descriptor
async def my_async_func():
@@ -545,19 +544,19 @@ class TestFunctionExecutor:
static_wrapped = staticmethod(my_async_func)
# Direct check on descriptor object fails (this is the bug)
assert not inspect.iscoroutinefunction(static_wrapped)
assert not asyncio.iscoroutinefunction(static_wrapped) # type: ignore[reportDeprecated]
assert isinstance(static_wrapped, staticmethod)
# But unwrapping __func__ reveals the async function
unwrapped = static_wrapped.__func__
assert inspect.iscoroutinefunction(unwrapped)
assert asyncio.iscoroutinefunction(unwrapped) # type: ignore[reportDeprecated]
# When accessed via class attribute, Python's descriptor protocol
# automatically unwraps it, so it works:
class C:
async_static = static_wrapped
assert inspect.iscoroutinefunction(C.async_static) # Works via descriptor protocol
assert asyncio.iscoroutinefunction(C.async_static) # type: ignore[reportDeprecated] # Works via descriptor protocol
class TestExecutorExplicitTypes:
File diff suppressed because it is too large Load Diff
+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.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260424"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
@@ -655,13 +655,7 @@ async def test_devui_streaming_renderer_memory_is_bounded(
)
try:
try:
websocket_url = await _get_devtools_websocket_url(debug_port)
except RuntimeError as exc:
return_code = browser_process.poll()
if return_code is not None:
pytest.skip(f"Chromium exited before DevTools became available (code {return_code}).")
pytest.skip(str(exc))
websocket_url = await _get_devtools_websocket_url(debug_port)
async with websocket_connect(websocket_url, max_size=None) as websocket:
client = _CDPClient(websocket)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260424"
version = "1.0.0b260421"
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.2.0,<2",
"agent-framework-core>=1.1.0,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
@@ -2,7 +2,7 @@
import importlib.metadata
from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient
from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient
from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
from ._embedding_client import (
FoundryEmbeddingClient,
@@ -25,7 +25,6 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"FoundryAgent",
"FoundryAgentOptions",
"FoundryChatClient",
"FoundryChatOptions",
"FoundryEmbeddingClient",
@@ -15,11 +15,10 @@ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequen
from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentMiddlewareLayer,
AgentSession,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ChatResponseUpdate,
ContextProvider,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -29,16 +28,13 @@ from agent_framework import (
load_settings,
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
from ._tools import sanitize_foundry_response_tool
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -56,13 +52,11 @@ else:
if TYPE_CHECKING:
from agent_framework import (
Agent,
AgentRunInputs,
ChatAndFunctionMiddlewareTypes,
ContextProvider,
MiddlewareTypes,
ToolTypes,
)
from agent_framework._agents import _RunContext # pyright: ignore[reportPrivateUsage]
logger: logging.Logger = logging.getLogger("agent_framework.foundry")
@@ -87,54 +81,14 @@ class FoundryAgentSettings(TypedDict, total=False):
agent_version: str | None
class FoundryAgentOptions(OpenAIChatOptions, total=False):
"""Microsoft Foundry agent-specific chat options.
Extends ``OpenAIChatOptions`` with hosted-agent session configuration used by
``FoundryAgent`` / ``RawFoundryAgent``.
Keyword Args:
extra_body: Additional request body values sent to the Responses API.
isolation_key: Isolation key used when lazily creating a hosted-agent
session through ``project_client.beta.agents.create_session(...)``.
"""
extra_body: dict[str, Any]
isolation_key: str
FoundryAgentOptionsT = TypeVar(
"FoundryAgentOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="FoundryAgentOptions",
default="OpenAIChatOptions",
covariant=True,
)
def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""Normalize and merge provider-specific extra_body values."""
if extra_body is None:
merged: dict[str, Any] = {}
elif isinstance(extra_body, Mapping):
merged = dict(cast(Mapping[str, Any], extra_body))
else:
raise TypeError(f"extra_body must be a mapping when provided, got {type(extra_body).__name__}.")
if additions:
merged.update(additions)
return merged
def _uses_foundry_agent_session(conversation_id: Any) -> bool:
"""Return whether a conversation_id should be treated as a Foundry agent session id."""
return (
isinstance(conversation_id, str)
and bool(conversation_id)
and not conversation_id.startswith("resp_")
and not conversation_id.startswith("conv_")
)
class RawFoundryAgentChatClient( # type: ignore[misc]
RawOpenAIChatClient[FoundryAgentOptionsT],
Generic[FoundryAgentOptionsT],
@@ -213,15 +167,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
)
resolved_endpoint = settings.get("project_endpoint")
agent_name_setting = settings.get("agent_name")
self.agent_version: str | None = settings.get("agent_version")
self.allow_preview = allow_preview or False
self.agent_name = settings.get("agent_name")
self.agent_version = settings.get("agent_version")
if not agent_name_setting:
if not self.agent_name:
raise ValueError(
"Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable."
)
self.agent_name = agent_name_setting
# Create or use provided project client
self._should_close_client = False
@@ -238,20 +190,18 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential,
"user_agent": get_user_agent(),
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
self.project_client = AIProjectClient(**project_client_kwargs)
self._should_close_client = True
openai_client_kwargs: dict[str, Any] = {}
if default_headers:
openai_client_kwargs["default_headers"] = dict(default_headers)
if allow_preview:
openai_client_kwargs["agent_name"] = self.agent_name
# Get OpenAI client from project
async_client = self.project_client.get_openai_client()
super().__init__(
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
async_client=async_client,
default_headers=default_headers,
instruction_role=instruction_role,
compaction_strategy=compaction_strategy,
@@ -259,6 +209,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
additional_properties=additional_properties,
)
def _get_agent_reference(self) -> dict[str, str]:
"""Build the agent reference dict for the Responses API."""
ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item]
if self.agent_version:
ref["version"] = self.agent_version
return ref
@override
def as_agent(
self,
@@ -313,7 +270,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
options: Mapping[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Prepare options for the Responses API and validate client-side tools."""
"""Prepare options for the Responses API, injecting agent reference and validating tools."""
# Validate tools — only FunctionTool allowed
tools = options.get("tools", [])
if tools:
@@ -335,61 +292,18 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
if "input" in run_options and isinstance(run_options["input"], list):
run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
# Merge caller-supplied extra_body with any agent-specific request payload.
conversation_id = options.get("conversation_id")
extra_body = _merge_extra_body(run_options.pop("extra_body", None))
if _uses_foundry_agent_session(conversation_id):
run_options.pop("previous_response_id", None)
run_options.pop("conversation", None)
extra_body["agent_session_id"] = conversation_id
if extra_body:
run_options["extra_body"] = extra_body
run_options.pop("isolation_key", None)
# Inject agent reference
run_options["extra_body"] = {"agent_reference": self._get_agent_reference()}
# Strip tools from request body - Foundry API rejects requests with both
# agent endpoint and tools present. FunctionTools are invoked client-side
# agent_reference and tools present. FunctionTools are invoked client-side
# by the function invocation layer, not sent to the service.
run_options.pop("model", None)
if not self.allow_preview:
run_options.pop("tools", None)
run_options.pop("tool_choice", None)
run_options.pop("parallel_tool_calls", None)
run_options.pop("tools", None)
run_options.pop("tool_choice", None)
run_options.pop("parallel_tool_calls", None)
return run_options
@override
def _parse_response_from_openai(
self,
response: Any,
options: dict[str, Any],
) -> Any:
parsed_response = super()._parse_response_from_openai(response, options)
if _uses_foundry_agent_session(options.get("conversation_id")):
parsed_response.conversation_id = None
return parsed_response
@override
def _parse_chunk_from_openai(
self,
event: Any,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse streaming events while preserving hosted-agent session state."""
update = try_parse_oauth_consent_event(event, self.model)
if update is None:
update = super()._parse_chunk_from_openai(
event,
options,
function_call_ids,
seen_reasoning_delta_item_ids,
)
if _uses_foundry_agent_session(options.get("conversation_id")):
update.conversation_id = None
return update
@override
def _check_model_presence(self, options: dict[str, Any]) -> None:
"""Skip model check — model is configured on the Foundry agent."""
@@ -407,7 +321,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
surface.
"""
response_tools = super()._prepare_tools_for_openai(tools)
return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
"""Extract system/developer messages as instructions for Azure AI.
@@ -454,26 +368,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
return transformed
async def get_agent_version(self) -> str | None:
"""Return the agent version if available, else None."""
if self.agent_version is not None:
return self.agent_version
if not self.allow_preview:
return None
agent_details = await cast(Any, self.project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
agent_name=self.agent_name
)
versions_object = getattr(agent_details, "versions", None)
if not isinstance(versions_object, Mapping):
raise TypeError("Foundry agent details did not include a versions mapping.")
versions = cast(Mapping[str, Any], versions_object)
latest_version = versions.get("latest")
agent_version = getattr(cast(Any, latest_version), "version", None)
if not isinstance(agent_version, str):
raise TypeError("Foundry agent details did not include a latest version string.")
self.agent_version = agent_version
return agent_version
async def close(self) -> None:
"""Close the project client if we created it."""
if self._should_close_client:
@@ -501,7 +395,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
client = FoundryAgentClient(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1",
agent_version="1.0",
credential=AzureCliCredential(),
)
@@ -583,7 +477,7 @@ class RawFoundryAgent( # type: ignore[misc]
agent = RawFoundryAgent(
project_endpoint="https://your-project.services.ai.azure.com",
agent_name="my-prompt-agent",
agent_version="1",
agent_version="1.0",
credential=AzureCliCredential(),
)
result = await agent.run("Hello!")
@@ -676,7 +570,7 @@ class RawFoundryAgent( # type: ignore[misc]
client=client, # type: ignore[arg-type]
instructions=instructions,
id=id,
name=name or agent_name,
name=name,
description=description,
tools=tools, # type: ignore[arg-type]
default_options=cast(FoundryAgentOptionsT | None, default_options),
@@ -688,81 +582,6 @@ class RawFoundryAgent( # type: ignore[misc]
additional_properties=dict(additional_properties) if additional_properties is not None else None,
)
def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str:
"""Resolve the isolation key from an explicit value or default_options."""
resolved_isolation_key = (
isolation_key if isolation_key is not None else self.default_options.get("isolation_key")
)
if resolved_isolation_key is None:
raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].")
return resolved_isolation_key
async def _create_service_session_id(
self,
*,
isolation_key: str | None = None,
) -> str:
"""Create a hosted Foundry service session and return the service session ID."""
if not isinstance(self.client, RawFoundryAgentChatClient):
raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.")
if not self.client.allow_preview:
raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.")
create_session_kwargs: dict[str, Any] = {
"agent_name": self.client.agent_name,
"isolation_key": self._resolve_service_session_isolation_key(isolation_key),
}
if version := await self.client.get_agent_version():
from azure.ai.projects.models import VersionRefIndicator
create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) # type: ignore
service_session = await self.client.project_client.beta.agents.create_session(**create_session_kwargs)
agent_session_id = getattr(service_session, "agent_session_id", None)
if not isinstance(agent_session_id, str) or not agent_session_id:
raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.")
return agent_session_id
@override
async def _prepare_run_context(
self,
*,
messages: AgentRunInputs | None,
session: AgentSession | None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
options: Mapping[str, Any] | None,
compaction_strategy: CompactionStrategy | None,
tokenizer: TokenizerProtocol | None,
function_invocation_kwargs: Mapping[str, Any] | None,
client_kwargs: Mapping[str, Any] | None,
) -> _RunContext:
runtime_options = dict(options) if options else {}
effective_options = {
**{key: value for key, value in self.default_options.items() if value is not None},
**{key: value for key, value in runtime_options.items() if value is not None},
}
if (
session is not None
and session.service_session_id is None
and effective_options.get("isolation_key") is not None
):
session.service_session_id = await self._create_service_session_id(
isolation_key=cast(str | None, effective_options.get("isolation_key")),
)
return await super()._prepare_run_context(
messages=messages,
session=session,
tools=tools,
options=runtime_options,
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
)
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
@@ -889,19 +708,6 @@ class FoundryAgent( # type: ignore[misc]
) -> None:
"""Initialize a Foundry Agent with full middleware and telemetry.
``FoundryAgent`` supports both PromptAgents and HostedAgents. PromptAgents
typically provide ``agent_version`` directly. HostedAgents can omit
``agent_version`` and, when they need preview-only session APIs, should
opt in with ``allow_preview=True`` when this class creates the underlying
``AIProjectClient``. If you pass ``project_client`` explicitly, it must
already be configured for preview APIs before being passed to
``FoundryAgent``.
To lazily create HostedAgent service sessions inside the agent, pass an
``isolation_key`` through ``default_options`` (or per-run options). The
agent stores the resulting HostedAgent session ID in
``AgentSession.service_session_id`` and reuses it on subsequent runs.
Keyword Args:
project_endpoint: The Foundry project endpoint URL.
agent_name: The name of the Foundry agent to connect to.
@@ -909,9 +715,6 @@ class FoundryAgent( # type: ignore[misc]
credential: Azure credential for authentication.
project_client: An existing AIProjectClient to use.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
Set this to ``True`` for HostedAgents that need preview-only
session APIs, including lazy service session creation from
``isolation_key``.
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
context_providers: Optional context providers.
middleware: Optional agent-level middleware.
@@ -923,8 +726,6 @@ class FoundryAgent( # type: ignore[misc]
description: Optional local description for the local agent wrapper.
instructions: Optional instructions for the local agent wrapper.
default_options: Default chat options for the local agent wrapper.
``FoundryAgentOptions`` can include ``isolation_key`` and
``extra_body`` when working with HostedAgents.
require_per_service_call_history_persistence: Whether to require per-service-call
chat history persistence when using local history providers.
function_invocation_configuration: Optional function invocation configuration override.
@@ -8,8 +8,8 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatMiddlewareLayer,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -17,7 +17,6 @@ from agent_framework import (
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
@@ -34,9 +33,7 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage]
from ._tools import fetch_toolbox, sanitize_foundry_response_tool
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -201,19 +198,15 @@ class RawFoundryChatClient( # type: ignore[misc]
project_client_kwargs: dict[str, Any] = {
"endpoint": project_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": get_user_agent(),
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
openai_kwargs: dict[str, Any] = {}
if default_headers:
openai_kwargs["default_headers"] = default_headers
super().__init__(
model=resolved_model,
async_client=project_client.get_openai_client(**openai_kwargs),
async_client=project_client.get_openai_client(),
default_headers=default_headers,
instruction_role=instruction_role,
compaction_strategy=compaction_strategy,
@@ -242,21 +235,7 @@ class RawFoundryChatClient( # type: ignore[misc]
them downstream.
"""
response_tools = super()._prepare_tools_for_openai(tools)
return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
@override
def _parse_chunk_from_openai(
self,
event: Any,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse streaming event, intercepting oauth_consent_request items."""
update = try_parse_oauth_consent_event(event, self.model)
if update is not None:
return update
return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids)
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
async def configure_azure_monitor(
self,
@@ -476,18 +455,8 @@ class RawFoundryChatClient( # type: ignore[misc]
Returns:
An MCPTool configuration ready to pass to an Agent.
Raises:
ValueError: If neither ``url`` nor ``project_connection_id`` is supplied
— one is required by the Foundry Responses API.
"""
if not url and not project_connection_id:
raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.")
mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs}
if url:
mcp_kwargs["server_url"] = url
mcp = FoundryMCPTool(**mcp_kwargs)
mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs)
if description:
mcp["server_description"] = description
@@ -14,13 +14,13 @@ from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Any, ClassVar
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentSession,
ContextProvider,
Message,
SessionContext,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -119,7 +119,7 @@ class FoundryMemoryProvider(ContextProvider):
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": get_user_agent(),
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
@@ -1,75 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse
from agent_framework import ChatResponseUpdate, Content
logger = logging.getLogger(__name__)
def _validate_consent_link(consent_link: str, item_id: str) -> str:
"""Validate a consent link is HTTPS with a valid netloc.
Returns the link unchanged if valid, or an empty string if not.
"""
parsed = urlparse(consent_link)
if parsed.scheme.lower() != "https" or not parsed.netloc:
logger.warning(
"Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)",
item_id,
)
return ""
return consent_link
def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None:
"""Parse an oauth_consent_request from a streaming event, if present.
Returns a ``ChatResponseUpdate`` when *event* is a
``response.output_item.added`` carrying an ``oauth_consent_request`` item
or a top-level ``response.oauth_consent_requested`` event,
or ``None`` so the caller can fall through to the base implementation.
"""
consent_link: str = ""
raw_item: Any = None
event_type = getattr(event, "type", None)
if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request":
raw_item = event.item
consent_link = getattr(raw_item, "consent_link", None) or ""
elif event_type == "response.oauth_consent_requested":
raw_item = event
consent_link = getattr(event, "consent_link", None) or ""
else:
return None
item_id = getattr(raw_item, "id", "<unknown>")
if consent_link:
consent_link = _validate_consent_link(consent_link, item_id)
contents: list[Content] = []
if consent_link:
contents.append(
Content.from_oauth_consent_request(
consent_link=consent_link,
raw_representation=raw_item,
)
)
else:
logger.warning(
"Received oauth_consent_request output without valid consent_link (item id=%s)",
item_id,
)
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=model,
raw_representation=event,
)

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