mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547316b523 | ||
|
|
424d66ad74 | ||
|
|
cd1f4c2a93 | ||
|
|
8d208f3bb3 | ||
|
|
373482427c | ||
|
|
c84203a4a8 | ||
|
|
8f878dcd58 | ||
|
|
c54483f81e | ||
|
|
275363d15e | ||
|
|
7417eeb7e6 | ||
|
|
6ad9279f0f | ||
|
|
c1bbaeb31d | ||
|
|
6173e63f0b |
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue author and check their team membership.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.github - Octokit REST client from actions/github-script
|
||||
* @param {object} opts.context - GitHub Actions context
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue number to resolve author for
|
||||
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
|
||||
*/
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
|
||||
let author = context.payload.issue?.user?.login;
|
||||
if (!author) {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(issueNumber),
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
core.setFailed('Could not determine issue author (user may be deleted).');
|
||||
return { author: null, isTeamMember: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.teams.getByName({
|
||||
org: context.repo.owner,
|
||||
team_slug: teamSlug,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: teamSlug,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Author ${author} is not a member of team ${teamSlug}.`);
|
||||
isTeamMember = false;
|
||||
} else {
|
||||
core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { author, isTeamMember };
|
||||
}
|
||||
|
||||
module.exports = checkTeamMembership;
|
||||
@@ -1,178 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for check_team_membership.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_check_team_membership.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
info(msg) { this._infoMessages.push(msg); },
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const context = {
|
||||
payload: { issue: payloadIssue },
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
const github = {
|
||||
rest: {
|
||||
issues: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
data: { state: teamState },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { core, context, github };
|
||||
}
|
||||
|
||||
const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Author resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('author resolution', () => {
|
||||
it('resolves author from event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'payload-user' } },
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'payload-user');
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue is absent', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: 'api-user' });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'api-user');
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue user is null (deleted account)', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: null },
|
||||
apiUser: 'fetched-user',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'fetched-user');
|
||||
});
|
||||
|
||||
it('handles deleted account when API also returns null user', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: null });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, null);
|
||||
assert.equal(result.isTeamMember, false);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('deleted')));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('team lookup', () => {
|
||||
it('fails the job when team lookup errors', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const error = new Error('Bad credentials');
|
||||
github.rest.teams.getByName = async () => { throw error; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === error,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed')));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team membership
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('team membership', () => {
|
||||
it('returns true for active team member', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'member' } },
|
||||
teamState: 'active',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, true);
|
||||
});
|
||||
|
||||
it('returns false for pending team member', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'pending-user' } },
|
||||
teamState: 'pending',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, false);
|
||||
});
|
||||
|
||||
it('treats 404 membership response as non-member without failing', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'outsider' } },
|
||||
});
|
||||
const notFoundError = new Error('Not Found');
|
||||
notFoundError.status = 404;
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; };
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, false);
|
||||
assert.equal(core._failedMessages.length, 0);
|
||||
assert.ok(core._infoMessages.some(m => m.includes('not a member')));
|
||||
});
|
||||
|
||||
it('fails the job on non-404 membership errors', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const serverError = new Error('Internal Server Error');
|
||||
serverError.status = 500;
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === serverError,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
|
||||
});
|
||||
|
||||
it('fails the job on membership errors without status code', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const networkError = new Error('ECONNREFUSED');
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === networkError,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: Issue number to triage
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||
repo: ${{ steps.issue.outputs.repo }}
|
||||
steps:
|
||||
- name: Resolve issue metadata
|
||||
id: issue
|
||||
shell: bash
|
||||
env:
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
||||
issue_number="${ISSUE_NUMBER_EVENT}"
|
||||
else
|
||||
issue_number="${ISSUE_NUMBER_INPUT}"
|
||||
fi
|
||||
|
||||
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check issue author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; skipping auto-triage.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a team member; proceeding with triage.`);
|
||||
}
|
||||
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
path: target-repo
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
token: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
- name: Install DevFlow dependencies
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Classify issue relevance
|
||||
id: spam
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
|
||||
run: |
|
||||
uv run python scripts/classify_issue_spam.py \
|
||||
--repo "$ISSUE_REPO" \
|
||||
--issue-number "$ISSUE_NUMBER" \
|
||||
--repo-path "${TARGET_REPO_PATH}" \
|
||||
--apply-labels
|
||||
|
||||
- name: Stop after spam gate
|
||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
||||
shell: bash
|
||||
env:
|
||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
||||
run: |
|
||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
||||
exit 1
|
||||
|
||||
- name: Reproduce reported issue
|
||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
|
||||
# Model-provider settings for generated repro code. Never enter the
|
||||
# agent prompt; consumed by SDK constructors via os.environ. Azure
|
||||
# OpenAI and Foundry auth via AAD from the azure/login step above.
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
|
||||
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
run: |
|
||||
uv run python scripts/trigger_issue_repro.py \
|
||||
--repo "$ISSUE_REPO" \
|
||||
--issue-number "$ISSUE_NUMBER" \
|
||||
--github-username "$GITHUB_ACTOR"
|
||||
@@ -87,14 +87,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
@@ -138,14 +130,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Misc integration tests (Anthropic, Hyperlight, Ollama, MCP)
|
||||
python-tests-misc-integration:
|
||||
@@ -189,14 +173,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 30
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
- name: Stop local MCP server
|
||||
if: always()
|
||||
shell: bash
|
||||
@@ -273,14 +249,6 @@ jobs:
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Foundry integration tests
|
||||
python-tests-foundry:
|
||||
@@ -327,14 +295,6 @@ jobs:
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
@@ -379,80 +339,7 @@ jobs:
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs:
|
||||
[
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore flaky report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
flaky-report-history-integration-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
../test-results/
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flaky-test-report
|
||||
path: |
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
|
||||
@@ -181,13 +181,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure OpenAI integration tests
|
||||
python-tests-azure-openai:
|
||||
@@ -251,13 +244,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Azure OpenAI integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-azure-openai
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Misc integration tests (Anthropic, Ollama, MCP)
|
||||
python-tests-misc-integration:
|
||||
@@ -335,13 +321,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Misc integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-misc
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Azure Functions + Durable Task integration tests
|
||||
python-tests-functions:
|
||||
@@ -413,13 +392,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Functions integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-functions
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
python-tests-foundry:
|
||||
name: Python Integration Tests - Foundry
|
||||
@@ -437,10 +409,6 @@ jobs:
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
|
||||
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
|
||||
FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -480,13 +448,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-foundry
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
@@ -536,7 +497,7 @@ jobs:
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
@@ -547,76 +508,6 @@ jobs:
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Cosmos integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-cosmos
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs:
|
||||
[
|
||||
python-tests-openai,
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore flaky report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
flaky-report-history-merge-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
../test-results/
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flaky-test-report
|
||||
path: |
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,281 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to dynamically expand the set of function tools available to an
|
||||
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
|
||||
// When the model calls RequestTools with a description of the capabilities needed, the function
|
||||
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
|
||||
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
// Pre-defined tool implementations that can be loaded on demand.
|
||||
[Description("Get the current weather for a city.")]
|
||||
static string GetWeather([Description("The city name.")] string city) =>
|
||||
city.ToUpperInvariant() switch
|
||||
{
|
||||
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
|
||||
"NEW YORK" => "New York: 72°F, sunny and warm.",
|
||||
"LONDON" => "London: 48°F, overcast with fog.",
|
||||
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
|
||||
};
|
||||
|
||||
[Description("Get the current local time for a city.")]
|
||||
static string GetTime([Description("The city name.")] string city) =>
|
||||
city.ToUpperInvariant() switch
|
||||
{
|
||||
"SEATTLE" => "Seattle: 9:00 AM PST",
|
||||
"NEW YORK" => "New York: 12:00 PM EST",
|
||||
"LONDON" => "London: 5:00 PM GMT",
|
||||
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
|
||||
};
|
||||
|
||||
[Description("Convert a temperature from Fahrenheit to Celsius.")]
|
||||
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
|
||||
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
|
||||
|
||||
// A registry of tool sets that can be loaded by description keyword.
|
||||
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
|
||||
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
|
||||
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
|
||||
};
|
||||
|
||||
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
|
||||
AIFunction requestToolsFunction = AIFunctionFactory.Create(
|
||||
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
|
||||
"Call this when you need capabilities that are not yet available in your current tool set.")] (
|
||||
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
|
||||
) =>
|
||||
{
|
||||
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
|
||||
var context = FunctionInvokingChatClient.CurrentContext
|
||||
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
|
||||
|
||||
var tools = context.Options?.Tools;
|
||||
if (tools is null)
|
||||
{
|
||||
return "Unable to register new tools: ChatOptions.Tools is not available.";
|
||||
}
|
||||
|
||||
// Find matching tool sets from the catalog.
|
||||
List<string> addedToolNames = [];
|
||||
foreach (var kvp in toolCatalog)
|
||||
{
|
||||
var keyword = kvp.Key;
|
||||
var catalogTools = kvp.Value;
|
||||
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var tool in catalogTools)
|
||||
{
|
||||
// Avoid adding duplicates.
|
||||
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
|
||||
{
|
||||
tools.Add(tool);
|
||||
addedToolNames.Add(fn.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addedToolNames.Count > 0
|
||||
? "Successfully loaded tools"
|
||||
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
|
||||
},
|
||||
name: "RequestTools");
|
||||
|
||||
// Create the agent with only the RequestTools function initially.
|
||||
// Insert chat client middleware that logs the tools available on each LLM call,
|
||||
// making the dynamic expansion visible in the console output.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
|
||||
.BuildAIAgent(
|
||||
instructions: """
|
||||
You are a helpful assistant. You start with limited tools.
|
||||
When you need functionality that you don't currently have, call RequestTools with a description
|
||||
of what you need. After new tools are loaded, use them to answer the user's question.
|
||||
""",
|
||||
tools: [requestToolsFunction]);
|
||||
|
||||
// Run a conversation that triggers dynamic tool expansion.
|
||||
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
|
||||
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the weather like in Seattle and London?",
|
||||
"What time is it in New York?",
|
||||
"Can you convert those temperatures to Celsius?"
|
||||
];
|
||||
|
||||
// --- Non-Streaming Mode ---
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("=== Non-Streaming Mode ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
foreach (var prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
|
||||
var response = await agent.RunAsync(prompt, session);
|
||||
|
||||
// Print all message contents including tool calls, tool results, and text.
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case FunctionCallContent functionCall:
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(textContent.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// --- Streaming Mode ---
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("=== Streaming Mode ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
AgentSession streamingSession = await agent.CreateSessionAsync();
|
||||
|
||||
foreach (var prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
|
||||
bool inAgentText = false;
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case FunctionCallContent functionCall:
|
||||
if (inAgentText)
|
||||
{
|
||||
Console.WriteLine();
|
||||
inAgentText = false;
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
|
||||
if (!inAgentText)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("[Agent] ");
|
||||
Console.ResetColor();
|
||||
inAgentText = true;
|
||||
}
|
||||
|
||||
Console.Write(textContent.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inAgentText)
|
||||
{
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Chat client middleware that logs the number and names of tools on each LLM request.
|
||||
async Task<ChatResponse> ToolLoggingMiddleware(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
IChatClient innerChatClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LogTools(options);
|
||||
|
||||
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Streaming version of the tool logging middleware.
|
||||
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
IChatClient innerChatClient,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
LogTools(options);
|
||||
|
||||
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared helper to log the current tool set.
|
||||
void LogTools(ChatOptions? options)
|
||||
{
|
||||
if (options?.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine(" [Middleware] LLM call with 0 tools");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# Dynamic Function Tools
|
||||
|
||||
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- The agent starts with only a single `RequestTools` function
|
||||
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
|
||||
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
|
||||
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
|
||||
|
||||
## How it works
|
||||
|
||||
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
|
||||
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
|
||||
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -46,7 +46,6 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|
||||
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
||||
// process does not invoke tools locally.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
||||
|
||||
// Replace with your own Foundry toolbox name.
|
||||
const string ToolboxName = "research_toolbox";
|
||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
||||
const string SecondToolboxName = "analysis_toolbox";
|
||||
// Replace with any question that exercises the tools configured in your toolbox.
|
||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
await Main(projectClient, model, endpoint);
|
||||
// await CombineToolboxes(projectClient, model, endpoint);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main: single toolbox
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
|
||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
||||
tools: tools.ToList());
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alternative: combine tools from multiple toolboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
||||
|
||||
// Comment out if the toolboxes already exist in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
||||
|
||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
||||
|
||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
||||
tools: allTools);
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
{
|
||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
// script, not the application itself. This helper exists so the sample can
|
||||
// be run end-to-end without first setting a toolbox up by hand.
|
||||
|
||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||
var options = new AgentAdministrationClientOptions();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
options);
|
||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist — nothing to delete.
|
||||
}
|
||||
|
||||
// Create a fresh version with a single MCP tool.
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: "api-specs",
|
||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
var created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [mcpTool],
|
||||
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
# Agent_Step25_ToolboxServerSideTools
|
||||
|
||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
||||
|
||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
||||
process does not invoke tools locally.
|
||||
|
||||
This is the dotnet equivalent of the Python sample:
|
||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
||||
|
||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
||||
an existing toolbox unchanged.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
||||
Foundry project API (resolving the default version if none is specified)
|
||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
||||
API request as server-side tool definitions
|
||||
|
||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
||||
|
||||
## Sample flows
|
||||
|
||||
| Flow | Description |
|
||||
|------|-------------|
|
||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
||||
|
||||
Uncomment the desired flow in the top-level statements to try each one.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
+3
-14
@@ -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)
|
||||
|
||||
+4
-2
@@ -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[]
|
||||
{
|
||||
|
||||
+27
-21
@@ -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);
|
||||
}
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
|
||||
+2
-11
@@ -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"
|
||||
}
|
||||
-11
@@ -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" }
|
||||
]
|
||||
}
|
||||
-11
@@ -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();
|
||||
|
||||
-63
@@ -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
@@ -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,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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user