mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5769c0e8ab | ||
|
|
bf4c0be52b | ||
|
|
a3362e2896 | ||
|
|
28a86d6d73 | ||
|
|
03f7dc86d3 | ||
|
|
69adf6d97e | ||
|
|
6851a9cdc8 | ||
|
|
dfca81ff21 | ||
|
|
fbbc2ebe86 | ||
|
|
c9e6033048 | ||
|
|
9ca55dcc0c |
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Resolve the issue author and check their team membership.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.github - Octokit REST client from actions/github-script
|
||||
* @param {object} opts.context - GitHub Actions context
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue number to resolve author for
|
||||
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
|
||||
*/
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
|
||||
let author = context.payload.issue?.user?.login;
|
||||
if (!author) {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(issueNumber),
|
||||
});
|
||||
author = issue.user?.login;
|
||||
}
|
||||
|
||||
if (!author) {
|
||||
core.setFailed('Could not determine issue author (user may be deleted).');
|
||||
return { author: null, isTeamMember: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.teams.getByName({
|
||||
org: context.repo.owner,
|
||||
team_slug: teamSlug,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let isTeamMember = false;
|
||||
try {
|
||||
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: teamSlug,
|
||||
username: author,
|
||||
});
|
||||
isTeamMember = teamMembership.data.state === 'active';
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Author ${author} is not a member of team ${teamSlug}.`);
|
||||
isTeamMember = false;
|
||||
} else {
|
||||
core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { author, isTeamMember };
|
||||
}
|
||||
|
||||
module.exports = checkTeamMembership;
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/**
|
||||
* Tests for check_team_membership.js.
|
||||
*
|
||||
* Run with: node --test .github/tests/test_check_team_membership.js
|
||||
*/
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const checkTeamMembership = require('../scripts/check_team_membership.js');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
|
||||
const core = {
|
||||
_infoMessages: [],
|
||||
_failedMessages: [],
|
||||
info(msg) { this._infoMessages.push(msg); },
|
||||
setFailed(msg) { this._failedMessages.push(msg); },
|
||||
};
|
||||
|
||||
const context = {
|
||||
payload: { issue: payloadIssue },
|
||||
repo: { owner: 'test-org', repo: 'test-repo' },
|
||||
};
|
||||
|
||||
const github = {
|
||||
rest: {
|
||||
issues: {
|
||||
get: async () => ({
|
||||
data: { user: apiUser ? { login: apiUser } : null },
|
||||
}),
|
||||
},
|
||||
teams: {
|
||||
getByName: async () => ({}),
|
||||
getMembershipForUserInOrg: async () => ({
|
||||
data: { state: teamState },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { core, context, github };
|
||||
}
|
||||
|
||||
const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Author resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('author resolution', () => {
|
||||
it('resolves author from event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'payload-user' } },
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'payload-user');
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue is absent', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: 'api-user' });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'api-user');
|
||||
});
|
||||
|
||||
it('resolves author via API when payload issue user is null (deleted account)', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: null },
|
||||
apiUser: 'fetched-user',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, 'fetched-user');
|
||||
});
|
||||
|
||||
it('handles deleted account when API also returns null user', async () => {
|
||||
const { github, context, core } = createMocks({ apiUser: null });
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.author, null);
|
||||
assert.equal(result.isTeamMember, false);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('deleted')));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('team lookup', () => {
|
||||
it('fails the job when team lookup errors', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const error = new Error('Bad credentials');
|
||||
github.rest.teams.getByName = async () => { throw error; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === error,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed')));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team membership
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('team membership', () => {
|
||||
it('returns true for active team member', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'member' } },
|
||||
teamState: 'active',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, true);
|
||||
});
|
||||
|
||||
it('returns false for pending team member', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'pending-user' } },
|
||||
teamState: 'pending',
|
||||
});
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, false);
|
||||
});
|
||||
|
||||
it('treats 404 membership response as non-member without failing', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'outsider' } },
|
||||
});
|
||||
const notFoundError = new Error('Not Found');
|
||||
notFoundError.status = 404;
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; };
|
||||
|
||||
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
|
||||
assert.equal(result.isTeamMember, false);
|
||||
assert.equal(core._failedMessages.length, 0);
|
||||
assert.ok(core._infoMessages.some(m => m.includes('not a member')));
|
||||
});
|
||||
|
||||
it('fails the job on non-404 membership errors', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const serverError = new Error('Internal Server Error');
|
||||
serverError.status = 500;
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === serverError,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
|
||||
});
|
||||
|
||||
it('fails the job on membership errors without status code', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'user1' } },
|
||||
});
|
||||
const networkError = new Error('ECONNREFUSED');
|
||||
github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; };
|
||||
|
||||
await assert.rejects(
|
||||
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
|
||||
(err) => err === networkError,
|
||||
);
|
||||
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: Issue number to triage
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_team_member: ${{ steps.check.outputs.is_team_member }}
|
||||
issue_number: ${{ steps.issue.outputs.issue_number }}
|
||||
repo: ${{ steps.issue.outputs.repo }}
|
||||
steps:
|
||||
- name: Resolve issue metadata
|
||||
id: issue
|
||||
shell: bash
|
||||
env:
|
||||
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
|
||||
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
||||
issue_number="${ISSUE_NUMBER_EVENT}"
|
||||
else
|
||||
issue_number="${ISSUE_NUMBER_INPUT}"
|
||||
fi
|
||||
|
||||
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check issue author team membership
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
script: |
|
||||
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
|
||||
const { author, isTeamMember } = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`Author ${author} is a team member; skipping auto-triage.`);
|
||||
} else {
|
||||
core.info(`Author ${author} is not a team member; proceeding with triage.`);
|
||||
}
|
||||
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
# Safe checkout: base repo only.
|
||||
- name: Checkout target repo base
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
path: target-repo
|
||||
|
||||
# Private DevFlow (maf-dashboard) checkout.
|
||||
- name: Checkout DevFlow
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.DEVFLOW_REPOSITORY }}
|
||||
ref: ${{ env.DEVFLOW_REF }}
|
||||
token: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
path: devflow
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.11.x"
|
||||
enable-cache: true
|
||||
|
||||
- name: Install DevFlow dependencies
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Classify issue relevance
|
||||
id: spam
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
|
||||
run: |
|
||||
uv run python scripts/classify_issue_spam.py \
|
||||
--repo "$ISSUE_REPO" \
|
||||
--issue-number "$ISSUE_NUMBER" \
|
||||
--repo-path "${TARGET_REPO_PATH}" \
|
||||
--apply-labels
|
||||
|
||||
- name: Stop after spam gate
|
||||
if: ${{ steps.spam.outputs.decision != 'allow' }}
|
||||
shell: bash
|
||||
env:
|
||||
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
|
||||
run: |
|
||||
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
|
||||
exit 1
|
||||
|
||||
- name: Reproduce reported issue
|
||||
if: ${{ steps.spam.outputs.decision == 'allow' }}
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
|
||||
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
|
||||
# Model-provider settings for generated repro code. Never enter the
|
||||
# agent prompt; consumed by SDK constructors via os.environ. Azure
|
||||
# OpenAI and Foundry auth via AAD from the azure/login step above.
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
|
||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
||||
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
|
||||
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
|
||||
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
|
||||
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
run: |
|
||||
uv run python scripts/trigger_issue_repro.py \
|
||||
--repo "$ISSUE_REPO" \
|
||||
--issue-number "$ISSUE_NUMBER" \
|
||||
--github-username "$GITHUB_ACTOR"
|
||||
@@ -4,9 +4,6 @@
|
||||
<BuildType Name="Publish" />
|
||||
<BuildType Name="Release" />
|
||||
</Configurations>
|
||||
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/">
|
||||
<File Path="samples/AGENTS.md" />
|
||||
<File Path="samples/README.md" />
|
||||
@@ -67,6 +64,7 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -533,6 +531,7 @@
|
||||
<File Path="tests/Directory.Build.props" />
|
||||
</Folder>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,281 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to dynamically expand the set of function tools available to an
|
||||
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
|
||||
// When the model calls RequestTools with a description of the capabilities needed, the function
|
||||
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
|
||||
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
// Pre-defined tool implementations that can be loaded on demand.
|
||||
[Description("Get the current weather for a city.")]
|
||||
static string GetWeather([Description("The city name.")] string city) =>
|
||||
city.ToUpperInvariant() switch
|
||||
{
|
||||
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
|
||||
"NEW YORK" => "New York: 72°F, sunny and warm.",
|
||||
"LONDON" => "London: 48°F, overcast with fog.",
|
||||
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
|
||||
};
|
||||
|
||||
[Description("Get the current local time for a city.")]
|
||||
static string GetTime([Description("The city name.")] string city) =>
|
||||
city.ToUpperInvariant() switch
|
||||
{
|
||||
"SEATTLE" => "Seattle: 9:00 AM PST",
|
||||
"NEW YORK" => "New York: 12:00 PM EST",
|
||||
"LONDON" => "London: 5:00 PM GMT",
|
||||
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
|
||||
};
|
||||
|
||||
[Description("Convert a temperature from Fahrenheit to Celsius.")]
|
||||
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
|
||||
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
|
||||
|
||||
// A registry of tool sets that can be loaded by description keyword.
|
||||
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
|
||||
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
|
||||
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
|
||||
};
|
||||
|
||||
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
|
||||
AIFunction requestToolsFunction = AIFunctionFactory.Create(
|
||||
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
|
||||
"Call this when you need capabilities that are not yet available in your current tool set.")] (
|
||||
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
|
||||
) =>
|
||||
{
|
||||
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
|
||||
var context = FunctionInvokingChatClient.CurrentContext
|
||||
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
|
||||
|
||||
var tools = context.Options?.Tools;
|
||||
if (tools is null)
|
||||
{
|
||||
return "Unable to register new tools: ChatOptions.Tools is not available.";
|
||||
}
|
||||
|
||||
// Find matching tool sets from the catalog.
|
||||
List<string> addedToolNames = [];
|
||||
foreach (var kvp in toolCatalog)
|
||||
{
|
||||
var keyword = kvp.Key;
|
||||
var catalogTools = kvp.Value;
|
||||
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var tool in catalogTools)
|
||||
{
|
||||
// Avoid adding duplicates.
|
||||
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
|
||||
{
|
||||
tools.Add(tool);
|
||||
addedToolNames.Add(fn.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addedToolNames.Count > 0
|
||||
? "Successfully loaded tools"
|
||||
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
|
||||
},
|
||||
name: "RequestTools");
|
||||
|
||||
// Create the agent with only the RequestTools function initially.
|
||||
// Insert chat client middleware that logs the tools available on each LLM call,
|
||||
// making the dynamic expansion visible in the console output.
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
|
||||
.BuildAIAgent(
|
||||
instructions: """
|
||||
You are a helpful assistant. You start with limited tools.
|
||||
When you need functionality that you don't currently have, call RequestTools with a description
|
||||
of what you need. After new tools are loaded, use them to answer the user's question.
|
||||
""",
|
||||
tools: [requestToolsFunction]);
|
||||
|
||||
// Run a conversation that triggers dynamic tool expansion.
|
||||
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
|
||||
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the weather like in Seattle and London?",
|
||||
"What time is it in New York?",
|
||||
"Can you convert those temperatures to Celsius?"
|
||||
];
|
||||
|
||||
// --- Non-Streaming Mode ---
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("=== Non-Streaming Mode ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
foreach (var prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
|
||||
var response = await agent.RunAsync(prompt, session);
|
||||
|
||||
// Print all message contents including tool calls, tool results, and text.
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case FunctionCallContent functionCall:
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(textContent.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// --- Streaming Mode ---
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("=== Streaming Mode ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
AgentSession streamingSession = await agent.CreateSessionAsync();
|
||||
|
||||
foreach (var prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
|
||||
bool inAgentText = false;
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case FunctionCallContent functionCall:
|
||||
if (inAgentText)
|
||||
{
|
||||
Console.WriteLine();
|
||||
inAgentText = false;
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResult:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
|
||||
if (!inAgentText)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("[Agent] ");
|
||||
Console.ResetColor();
|
||||
inAgentText = true;
|
||||
}
|
||||
|
||||
Console.Write(textContent.Text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inAgentText)
|
||||
{
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Chat client middleware that logs the number and names of tools on each LLM request.
|
||||
async Task<ChatResponse> ToolLoggingMiddleware(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
IChatClient innerChatClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LogTools(options);
|
||||
|
||||
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
|
||||
}
|
||||
|
||||
// Streaming version of the tool logging middleware.
|
||||
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
IChatClient innerChatClient,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
LogTools(options);
|
||||
|
||||
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared helper to log the current tool set.
|
||||
void LogTools(ChatOptions? options)
|
||||
{
|
||||
if (options?.Tools is { Count: > 0 } tools)
|
||||
{
|
||||
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkGray;
|
||||
Console.WriteLine(" [Middleware] LLM call with 0 tools");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Dynamic Function Tools
|
||||
|
||||
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- The agent starts with only a single `RequestTools` function
|
||||
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
|
||||
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
|
||||
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
|
||||
|
||||
## How it works
|
||||
|
||||
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
|
||||
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
|
||||
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|
||||
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+14
-3
@@ -2,14 +2,22 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
|
||||
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- Suppress analyzer warnings for Aspire integration code -->
|
||||
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
|
||||
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework DevUI for Aspire</Title>
|
||||
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
|
||||
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
|
||||
</ItemGroup>
|
||||
@@ -22,4 +30,7 @@
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -77,12 +77,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for the first input before starting
|
||||
// The consumer will call EnqueueMessageAsync which signals the run loop
|
||||
// Wait for the first input before starting.
|
||||
// The consumer will call EnqueueMessageAsync which signals the run loop.
|
||||
// Note: AsyncRunHandle also signals here on checkpoint resume when there are
|
||||
// already pending requests, so the first iteration can emit a PendingRequests
|
||||
// halt signal even without unprocessed messages.
|
||||
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
this._runStatus = RunStatus.Running;
|
||||
|
||||
while (!linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
// Start a new run-stage activity for this input→processing→halt cycle
|
||||
@@ -95,6 +96,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Events are streamed out in real-time as they happen via the event handler
|
||||
if (this._stepRunner.HasUnprocessedMessages)
|
||||
{
|
||||
// Flip to Running only when there's actual work to process.
|
||||
// This is intentionally inside the HasUnprocessedMessages branch so
|
||||
// that stale input signals cannot transiently flip status back to
|
||||
// Running after a prior halt has already been observed by callers
|
||||
// (e.g. Run.ResumeAsync returning after reading an Idle halt signal).
|
||||
this._runStatus = RunStatus.Running;
|
||||
|
||||
// Emit WorkflowStartedEvent only when there's actual work to process
|
||||
// This avoids spurious events on timeout-only loop iterations
|
||||
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false);
|
||||
@@ -129,9 +137,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Wait for next input from the consumer
|
||||
// Works for both Idle (no work) and PendingRequests (waiting for responses)
|
||||
await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// When signaled, resume running
|
||||
this._runStatus = RunStatus.Running;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
@@ -269,6 +269,29 @@ public class SampleSmokeTest
|
||||
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stress regression for the off-thread run-status race: after
|
||||
/// <c>Run.ResumeAsync</c> returns at a halt boundary,
|
||||
/// callers must observe a stable terminal status and never a transient
|
||||
/// <see cref="RunStatus.Running"/>. Step9 is the canonical multi-response resume
|
||||
/// sample; prior to the fix in <see cref="Execution.StreamingRunEventStream"/>,
|
||||
/// its `runStatus.Should().Be(RunStatus.Idle)` assertion failed intermittently
|
||||
/// on roughly 1-in-10 iterations under InProcess_OffThread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
internal async Task Test_RunSample_Step9_OffThread_MultiResponseResume_StatusIsStableAsync()
|
||||
{
|
||||
const int Iterations = 50;
|
||||
|
||||
for (int i = 0; i < Iterations; i++)
|
||||
{
|
||||
using StringWriter writer = new();
|
||||
_ = await Step9EntryPoint.RunAsync(
|
||||
writer,
|
||||
ExecutionEnvironment.InProcess_OffThread.ToWorkflowExecutionEnvironment());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
|
||||
@@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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))
|
||||
@@ -24,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ from collections.abc import Sequence
|
||||
from typing import Any, ClassVar, Generic, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from anthropic import AsyncAnthropicBedrock
|
||||
|
||||
@@ -94,7 +94,7 @@ class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[A
|
||||
aws_profile=settings.get("aws_profile"),
|
||||
aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None,
|
||||
base_url=settings.get("anthropic_bedrock_base_url"),
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -8,7 +8,6 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequenc
|
||||
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
Annotation,
|
||||
BaseChatClient,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
@@ -28,6 +27,7 @@ from agent_framework import (
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
|
||||
from agent_framework._types import _get_data_bytes_as_str # type: ignore
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
@@ -332,7 +332,7 @@ class RawAnthropicClient(
|
||||
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key=api_key_secret.get_secret_value(),
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
# Initialize parent
|
||||
@@ -604,7 +604,7 @@ class RawAnthropicClient(
|
||||
run_options["betas"] = self._prepare_betas(options)
|
||||
|
||||
# extra headers
|
||||
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}
|
||||
run_options["extra_headers"] = {"User-Agent": get_user_agent()}
|
||||
|
||||
# Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user)
|
||||
if user := run_options.pop("user", None):
|
||||
|
||||
@@ -6,13 +6,13 @@ from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any, ClassVar, Generic, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from anthropic import AsyncAnthropicFoundry
|
||||
|
||||
@@ -91,14 +91,14 @@ class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[A
|
||||
base_url=base_url_setting,
|
||||
api_key=api_key_value,
|
||||
azure_ad_token_provider=azure_ad_token_provider,
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
else:
|
||||
anthropic_client = AsyncAnthropicFoundry(
|
||||
resource=resource_setting,
|
||||
api_key=api_key_value,
|
||||
azure_ad_token_provider=azure_ad_token_provider,
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -6,13 +6,13 @@ from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from anthropic import NOT_GIVEN, AsyncAnthropicVertex
|
||||
|
||||
@@ -89,7 +89,7 @@ class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[An
|
||||
access_token=access_token,
|
||||
credentials=credentials,
|
||||
base_url=settings.get("anthropic_vertex_base_url"),
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer
|
||||
from agent_framework import ChatMiddlewareLayer, FunctionInvocationLayer
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
|
||||
from agent_framework_anthropic import (
|
||||
@@ -61,7 +62,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path)
|
||||
resource="test-resource",
|
||||
api_key="test-key",
|
||||
azure_ad_token_provider=None,
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +86,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings(
|
||||
base_url="https://test-resource.services.ai.azure.com/anthropic/",
|
||||
api_key="test-key",
|
||||
azure_ad_token_provider=None,
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
|
||||
@@ -130,7 +131,7 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non
|
||||
aws_profile=None,
|
||||
aws_session_token=None,
|
||||
base_url=None,
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
|
||||
@@ -152,5 +153,5 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None
|
||||
access_token=None,
|
||||
credentials=None,
|
||||
base_url=None,
|
||||
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
|
||||
default_headers={"User-Agent": get_user_agent()},
|
||||
)
|
||||
|
||||
+6
-6
@@ -14,7 +14,6 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
AgentSession,
|
||||
Annotation,
|
||||
Content,
|
||||
@@ -25,6 +24,7 @@ from agent_framework import (
|
||||
SupportsGetEmbeddings,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from azure.core.credentials import AzureKeyCredential, TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
@@ -535,7 +535,7 @@ class AzureAISearchContextProvider(ContextProvider):
|
||||
endpoint=self.endpoint,
|
||||
index_name=self.index_name,
|
||||
credential=self.credential,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
|
||||
self._index_client: SearchIndexClient | None = None
|
||||
@@ -544,7 +544,7 @@ class AzureAISearchContextProvider(ContextProvider):
|
||||
self._index_client = SearchIndexClient(
|
||||
endpoint=self.endpoint,
|
||||
credential=self.credential,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
|
||||
self._knowledge_base_initialized = False
|
||||
@@ -640,7 +640,7 @@ class AzureAISearchContextProvider(ContextProvider):
|
||||
self._index_client = SearchIndexClient(
|
||||
endpoint=self.endpoint,
|
||||
credential=self.credential,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
if not self.index_name:
|
||||
logger.warning("Cannot auto-discover vector field: index_name is not set.")
|
||||
@@ -740,7 +740,7 @@ class AzureAISearchContextProvider(ContextProvider):
|
||||
endpoint=self.endpoint,
|
||||
knowledge_base_name=knowledge_base_name,
|
||||
credential=self.credential,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
self._knowledge_base_initialized = True
|
||||
return
|
||||
@@ -802,7 +802,7 @@ class AzureAISearchContextProvider(ContextProvider):
|
||||
endpoint=self.endpoint,
|
||||
knowledge_base_name=knowledge_base_name,
|
||||
credential=self.credential,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
|
||||
async def _agentic_search(self, messages: list[Message]) -> list[Message]:
|
||||
|
||||
@@ -7,8 +7,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
|
||||
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from agent_framework.exceptions import WorkflowCheckpointException
|
||||
@@ -194,7 +194,7 @@ class CosmosCheckpointStorage:
|
||||
self._cosmos_client = CosmosClient(
|
||||
url=settings["endpoint"], # type: ignore[arg-type]
|
||||
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
|
||||
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent_suffix=get_user_agent(),
|
||||
)
|
||||
self._owns_client = True
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, ClassVar, TypedDict
|
||||
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
|
||||
from agent_framework import Message
|
||||
from agent_framework._sessions import HistoryProvider
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.cosmos import PartitionKey
|
||||
@@ -121,7 +122,7 @@ class CosmosHistoryProvider(HistoryProvider):
|
||||
self._cosmos_client = CosmosClient(
|
||||
url=settings["endpoint"], # type: ignore[arg-type]
|
||||
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
|
||||
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent_suffix=get_user_agent(),
|
||||
)
|
||||
self._owns_client = True
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
BaseChatClient,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
@@ -31,6 +30,7 @@ from agent_framework import (
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.exceptions import ChatClientInvalidResponseException
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from boto3.session import Session as Boto3Session
|
||||
@@ -299,7 +299,7 @@ class BedrockChatClient(
|
||||
self._bedrock_client = session.client(
|
||||
"bedrock-runtime",
|
||||
region_name=region,
|
||||
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
|
||||
config=BotoConfig(user_agent_extra=get_user_agent()),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -11,7 +11,6 @@ from collections.abc import Sequence
|
||||
from typing import Any, ClassVar, Generic, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
BaseEmbeddingClient,
|
||||
Embedding,
|
||||
EmbeddingGenerationOptions,
|
||||
@@ -20,6 +19,7 @@ from agent_framework import (
|
||||
UsageDetails,
|
||||
load_settings,
|
||||
)
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import EmbeddingTelemetryLayer
|
||||
from boto3.session import Session as Boto3Session
|
||||
from botocore.client import BaseClient
|
||||
@@ -140,7 +140,7 @@ class RawBedrockEmbeddingClient(
|
||||
self._bedrock_client = boto3_session.client(
|
||||
"bedrock-runtime",
|
||||
region_name=region_name or resolved_region,
|
||||
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
|
||||
config=BotoConfig(user_agent_extra=get_user_agent()),
|
||||
)
|
||||
|
||||
self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess]
|
||||
|
||||
@@ -4,9 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Final
|
||||
|
||||
from . import __version__ as version_info
|
||||
@@ -29,34 +26,73 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
|
||||
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
|
||||
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
|
||||
|
||||
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
|
||||
# This environment variable is reserved by the Foundry hosting environment to
|
||||
# indicate that the agent is running in a hosted environment.
|
||||
_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT"
|
||||
# This prefix is added to the user agent string when the agent is running in a hosted environment.
|
||||
_HOSTED_USER_AGENT_PREFIX = "foundry-hosting"
|
||||
|
||||
_user_agent_prefixes: set[str] = set()
|
||||
_hosted_env_detected: bool = False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def user_agent_prefix(prefix: str) -> Generator[None]:
|
||||
"""Context manager that adds a prefix to the user agent string for the current scope.
|
||||
def _add_user_agent_prefix(prefix: str) -> None:
|
||||
"""Permanently add a prefix to the user agent string.
|
||||
|
||||
This is useful for upstream layers that want to identify themselves in telemetry
|
||||
for the duration of a request without permanently mutating global state.
|
||||
This is used by hosting layers to identify themselves in telemetry.
|
||||
Once added, the prefix applies to all subsequent user agent strings.
|
||||
|
||||
Args:
|
||||
prefix: The prefix to add (e.g. "foundry-hosting").
|
||||
"""
|
||||
current = _user_agent_prefixes.get()
|
||||
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
|
||||
if prefix:
|
||||
_user_agent_prefixes.add(prefix)
|
||||
|
||||
|
||||
def _detect_hosted_environment() -> None:
|
||||
"""Detect if running in a hosted environment and add the user agent prefix.
|
||||
|
||||
Checks the ``FOUNDRY_HOSTING_ENVIRONMENT`` env var first, then falls back
|
||||
to checking whether the agent server SDK is installed (via
|
||||
``importlib.util.find_spec``) before importing it, to avoid unnecessary
|
||||
import overhead for non-hosted scenarios.
|
||||
"""
|
||||
global _hosted_env_detected
|
||||
if _hosted_env_detected:
|
||||
return
|
||||
_hosted_env_detected = True
|
||||
|
||||
env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)
|
||||
if env_value is not None:
|
||||
# Env var exists — trust its value and skip the fallback.
|
||||
if env_value:
|
||||
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
|
||||
return
|
||||
|
||||
# Env var not set — fall back to AgentConfig as a second layer of defense.
|
||||
# Use find_spec to avoid the cost of a full import when the SDK is not installed.
|
||||
import importlib.util
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if token is not None:
|
||||
_user_agent_prefixes.reset(token)
|
||||
if importlib.util.find_spec("azure.ai.agentserver.core") is None:
|
||||
return
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return
|
||||
try:
|
||||
from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports]
|
||||
|
||||
if AgentConfig.from_env().is_hosted:
|
||||
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def _get_user_agent() -> str:
|
||||
"""Return the full user agent string including any context-scoped prefixes."""
|
||||
prefixes = _user_agent_prefixes.get()
|
||||
if not prefixes:
|
||||
def get_user_agent() -> str:
|
||||
"""Return the full user agent string including any registered prefixes."""
|
||||
_detect_hosted_environment()
|
||||
if not _user_agent_prefixes:
|
||||
return AGENT_FRAMEWORK_USER_AGENT
|
||||
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
|
||||
|
||||
|
||||
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -89,7 +125,7 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
|
||||
"""
|
||||
if not IS_TELEMETRY_ENABLED:
|
||||
return headers or {}
|
||||
user_agent = _get_user_agent()
|
||||
user_agent = get_user_agent()
|
||||
if not headers:
|
||||
return {USER_AGENT_KEY: user_agent}
|
||||
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import patch
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import agent_framework._telemetry as _telemetry_mod
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
USER_AGENT_KEY,
|
||||
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from agent_framework._telemetry import user_agent_prefix
|
||||
from agent_framework._telemetry import (
|
||||
_FOUNDRY_HOSTING_ENV_VAR,
|
||||
_HOSTED_USER_AGENT_PREFIX,
|
||||
_add_user_agent_prefix,
|
||||
_detect_hosted_environment,
|
||||
)
|
||||
|
||||
# region Test constants
|
||||
|
||||
@@ -83,7 +90,7 @@ def test_prepend_to_empty_headers():
|
||||
|
||||
def test_prepend_to_empty_dict():
|
||||
"""Test prepending to empty headers dict."""
|
||||
headers = {}
|
||||
headers: dict[str, str] = {}
|
||||
result = prepend_agent_framework_to_user_agent(headers)
|
||||
|
||||
assert "User-Agent" in result
|
||||
@@ -99,54 +106,184 @@ def test_modifies_original_dict():
|
||||
assert "User-Agent" in headers
|
||||
|
||||
|
||||
# region Test user_agent_prefix context manager
|
||||
# region Test _add_user_agent_prefix
|
||||
|
||||
|
||||
def test_user_agent_prefix_adds_prefix():
|
||||
"""Test that the context manager adds a prefix within its scope."""
|
||||
with user_agent_prefix("test-host"):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"].startswith("test-host/")
|
||||
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
|
||||
|
||||
# Prefix is removed after exiting the context
|
||||
def test_add_user_agent_prefix_adds_prefix():
|
||||
"""Test that _add_user_agent_prefix permanently adds a prefix."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_add_user_agent_prefix("test-host")
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
assert result["User-Agent"].startswith("test-host/")
|
||||
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
|
||||
|
||||
def test_user_agent_prefix_ignores_duplicates():
|
||||
"""Test that duplicate prefixes are not added within nested scopes."""
|
||||
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"].count("test-host") == 1
|
||||
def test_add_user_agent_prefix_ignores_duplicates():
|
||||
"""Test that duplicate prefixes are not added."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_add_user_agent_prefix("test-host")
|
||||
_add_user_agent_prefix("test-host")
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"].count("test-host") == 1
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
|
||||
|
||||
def test_user_agent_prefix_ignores_empty():
|
||||
def test_add_user_agent_prefix_ignores_empty():
|
||||
"""Test that empty strings are not added as prefixes."""
|
||||
with user_agent_prefix(""):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
|
||||
|
||||
def test_user_agent_prefix_restores_on_exit():
|
||||
"""Test that prefixes are fully restored after the context manager exits."""
|
||||
with user_agent_prefix("test-host"):
|
||||
pass
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_add_user_agent_prefix("")
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
|
||||
|
||||
def test_user_agent_prefix_nesting():
|
||||
"""Test that nested context managers compose prefixes correctly."""
|
||||
with user_agent_prefix("outer"):
|
||||
with user_agent_prefix("inner"):
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert "outer" in result["User-Agent"]
|
||||
assert "inner" in result["User-Agent"]
|
||||
# Inner prefix removed
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert "outer" in result["User-Agent"]
|
||||
assert "inner" not in result["User-Agent"]
|
||||
# Both removed
|
||||
def test_add_user_agent_prefix_multiple():
|
||||
"""Test that multiple prefixes compose correctly."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_add_user_agent_prefix("outer")
|
||||
_add_user_agent_prefix("inner")
|
||||
result = prepend_agent_framework_to_user_agent()
|
||||
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
assert "outer" in result["User-Agent"]
|
||||
assert "inner" in result["User-Agent"]
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
|
||||
|
||||
# region Test _detect_hosted_environment
|
||||
|
||||
|
||||
def test_detect_hosted_env_var_truthy_adds_prefix():
|
||||
"""Test that a truthy FOUNDRY_HOSTING_ENVIRONMENT env var adds the prefix."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}):
|
||||
_detect_hosted_environment()
|
||||
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
|
||||
def test_detect_hosted_env_var_empty_skips_prefix():
|
||||
"""Test that an empty FOUNDRY_HOSTING_ENVIRONMENT env var does NOT add the prefix."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: ""}):
|
||||
_detect_hosted_environment()
|
||||
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
|
||||
def test_detect_hosted_env_var_set_skips_agent_config_fallback():
|
||||
"""Test that when the env var is set, AgentConfig is never consulted even if import would fail."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
if "agentserver" in name:
|
||||
raise AssertionError("AgentConfig should not be imported when env var is set")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "prod"}),
|
||||
patch("builtins.__import__", side_effect=_block_agentconfig),
|
||||
):
|
||||
_detect_hosted_environment()
|
||||
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
|
||||
def _mock_agent_config(*, is_hosted: bool) -> MagicMock:
|
||||
"""Create a mock azure.ai.agentserver.core module with AgentConfig."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.is_hosted = is_hosted
|
||||
mock_module = MagicMock()
|
||||
mock_module.AgentConfig.from_env.return_value = mock_config
|
||||
return mock_module
|
||||
|
||||
|
||||
def test_detect_hosted_fallback_agent_config_is_hosted():
|
||||
"""Test that AgentConfig fallback adds the prefix when is_hosted is True."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
|
||||
mock_module = _mock_agent_config(is_hosted=True)
|
||||
mock_spec = MagicMock()
|
||||
with (
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}),
|
||||
patch("importlib.util.find_spec", return_value=mock_spec),
|
||||
):
|
||||
_detect_hosted_environment()
|
||||
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
|
||||
def test_detect_hosted_fallback_agent_config_not_hosted():
|
||||
"""Test that AgentConfig fallback does NOT add the prefix when is_hosted is False."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
mock_module = _mock_agent_config(is_hosted=False)
|
||||
mock_spec = MagicMock()
|
||||
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
|
||||
with (
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}),
|
||||
patch("importlib.util.find_spec", return_value=mock_spec),
|
||||
):
|
||||
_detect_hosted_environment()
|
||||
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
|
||||
def test_detect_hosted_fallback_import_error():
|
||||
"""Test that ImportError from AgentConfig is silently handled."""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
# The real import may succeed or fail depending on the environment;
|
||||
# force the ImportError path by making the import raise.
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
if "agentserver" in name:
|
||||
raise ImportError("mocked")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=_block_agentconfig):
|
||||
_detect_hosted_environment()
|
||||
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
|
||||
# region Test module-level auto-detection
|
||||
|
||||
|
||||
def test_lazy_detection_on_get_user_agent():
|
||||
"""Test that get_user_agent() lazily detects the hosted environment.
|
||||
|
||||
Since detection is deferred to the first ``get_user_agent()`` call,
|
||||
this verifies the prefix is included without any explicit call to
|
||||
``_detect_hosted_environment()`` by consumer code.
|
||||
"""
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}):
|
||||
user_agent = _telemetry_mod.get_user_agent()
|
||||
|
||||
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
|
||||
assert user_agent.startswith(f"{_HOSTED_USER_AGENT_PREFIX}/")
|
||||
|
||||
# Clean up
|
||||
_telemetry_mod._user_agent_prefixes.clear()
|
||||
_telemetry_mod._hosted_env_detected = False
|
||||
|
||||
@@ -15,7 +15,6 @@ 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,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
@@ -28,6 +27,7 @@ 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
|
||||
@@ -190,7 +190,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": resolved_endpoint,
|
||||
"credential": credential,
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
"user_agent": get_user_agent(),
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
Content,
|
||||
FunctionInvocationConfiguration,
|
||||
@@ -17,6 +16,7 @@ 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
|
||||
@@ -198,7 +198,7 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
project_client_kwargs: dict[str, Any] = {
|
||||
"endpoint": project_endpoint,
|
||||
"credential": credential, # type: ignore[arg-type]
|
||||
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
"user_agent": get_user_agent(),
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
|
||||
@@ -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": AGENT_FRAMEWORK_USER_AGENT,
|
||||
"user_agent": get_user_agent(),
|
||||
}
|
||||
if allow_preview is not None:
|
||||
project_client_kwargs["allow_preview"] = allow_preview
|
||||
|
||||
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool
|
||||
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
|
||||
from agent_framework_openai import OpenAIContentFilterException
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
@@ -199,7 +199,7 @@ def test_init_with_project_endpoint_creates_project_client() -> None:
|
||||
assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT
|
||||
assert factory.call_args.kwargs["credential"] is credential
|
||||
assert factory.call_args.kwargs["allow_preview"] is True
|
||||
assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT
|
||||
assert factory.call_args.kwargs["user_agent"] == get_user_agent()
|
||||
|
||||
|
||||
def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -7,8 +7,9 @@ import os
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message
|
||||
from agent_framework import AgentResponse, Message
|
||||
from agent_framework._sessions import AgentSession, SessionContext
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
|
||||
from agent_framework_foundry._memory_provider import FoundryMemoryProvider
|
||||
|
||||
@@ -94,7 +95,7 @@ def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMoc
|
||||
endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential,
|
||||
allow_preview=True,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
|
||||
from agent_framework._telemetry import user_agent_prefix
|
||||
from azure.ai.agentserver.invocations import InvocationAgentServerHost
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
@@ -11,8 +10,6 @@ from typing_extensions import Any, AsyncGenerator
|
||||
class InvocationsHostServer(InvocationAgentServerHost):
|
||||
"""An invocations server host for an agent."""
|
||||
|
||||
USER_AGENT_PREFIX = "foundry-hosting"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: BaseAgent,
|
||||
@@ -42,11 +39,6 @@ class InvocationsHostServer(InvocationAgentServerHost):
|
||||
|
||||
async def _handle_invoke(self, request: Request) -> Response:
|
||||
"""Invoke the agent with the given request."""
|
||||
with user_agent_prefix(self.USER_AGENT_PREFIX):
|
||||
return await self._handle_invoke_inner(request)
|
||||
|
||||
async def _handle_invoke_inner(self, request: Request) -> Response:
|
||||
"""Core invoke handler logic."""
|
||||
data = await request.json()
|
||||
session_id: str = request.state.session_id
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
)
|
||||
from agent_framework._telemetry import user_agent_prefix
|
||||
from azure.ai.agentserver.responses import (
|
||||
ResponseContext,
|
||||
ResponseEventStream,
|
||||
@@ -90,7 +89,6 @@ logger = logging.getLogger(__name__)
|
||||
class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
"""A responses server host for an agent."""
|
||||
|
||||
USER_AGENT_PREFIX = "foundry-hosting"
|
||||
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
|
||||
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
|
||||
|
||||
@@ -150,37 +148,32 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
self._is_workflow_agent = True
|
||||
|
||||
self._agent = agent
|
||||
self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType]
|
||||
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
@staticmethod
|
||||
def _is_streaming_request(request: CreateResponse) -> bool:
|
||||
"""Check if the request is a streaming request."""
|
||||
return request.stream is not None and request.stream is True
|
||||
|
||||
async def _handler(
|
||||
def _handle_response(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
cancellation_signal: asyncio.Event,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Handle the creation of a response."""
|
||||
with user_agent_prefix(self.USER_AGENT_PREFIX):
|
||||
async for event in self._handle_inner(request, context, cancellation_signal):
|
||||
yield event
|
||||
if self._is_workflow_agent:
|
||||
# Workflow agents are handled differently because they require checkpoint restoration
|
||||
return self._handle_workflow_agent(request, context)
|
||||
|
||||
async def _handle_inner(
|
||||
return self._handle_regular_agent(request, context)
|
||||
|
||||
async def _handle_regular_agent(
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
cancellation_signal: asyncio.Event,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Core handler logic."""
|
||||
if self._is_workflow_agent:
|
||||
# Workflow agents are handled differently because they require checkpoint restoration
|
||||
async for event in self._handle_workflow_agent(request, context, cancellation_signal):
|
||||
yield event
|
||||
return
|
||||
|
||||
"""Handle the creation of a response for a regular (non-workflow) agent."""
|
||||
input_text = await context.get_input_text()
|
||||
history = await context.get_history()
|
||||
messages: list[str | Content | Message] = [*_to_messages(history), input_text]
|
||||
@@ -243,7 +236,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
self,
|
||||
request: CreateResponse,
|
||||
context: ResponseContext,
|
||||
cancellation_signal: asyncio.Event,
|
||||
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
|
||||
"""Handle the creation of a response for a workflow agent.
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any, ClassVar, Generic, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
BaseChatClient,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
@@ -28,6 +27,7 @@ from agent_framework import (
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from google import genai
|
||||
from google.auth.credentials import Credentials
|
||||
@@ -355,7 +355,7 @@ class RawGeminiChatClient(
|
||||
)
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"http_options": {"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}},
|
||||
"http_options": {"headers": {"x-goog-api-client": get_user_agent()}},
|
||||
}
|
||||
if configured_vertexai is not None:
|
||||
client_kwargs["vertexai"] = configured_vertexai
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any, Literal, TypeVar, Union, overload
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.observability import get_tracer
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
@@ -189,7 +189,7 @@ class PurviewClient:
|
||||
payload = model.model_dump(by_alias=True, exclude_none=True, mode="json")
|
||||
request_headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": AGENT_FRAMEWORK_USER_AGENT,
|
||||
"User-Agent": get_user_agent(),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if correlation_id:
|
||||
|
||||
Reference in New Issue
Block a user