mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01fc518b29 | ||
|
|
f3c3efed43 | ||
|
|
bbccb7c28c | ||
|
|
dbc312a78a | ||
|
|
bb9ed63a34 | ||
|
|
6b94315161 | ||
|
|
bc0e65d716 | ||
|
|
4268080c20 | ||
|
|
fe08574a7c | ||
|
|
f970a699d8 | ||
|
|
f29bae8fbc | ||
|
|
c3901a4ddd | ||
|
|
ba617fc3b5 | ||
|
|
afa7834e2e | ||
|
|
c6951c21f6 | ||
|
|
a982428916 | ||
|
|
90a3e5de47 | ||
|
|
49a6e433a3 |
@@ -8,6 +8,7 @@ function getPullRequest(context) {
|
||||
|
||||
return {
|
||||
author: pullRequest.user.login,
|
||||
authorType: pullRequest.user.type,
|
||||
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
|
||||
number: pullRequest.number,
|
||||
};
|
||||
@@ -49,6 +50,10 @@ function hasLabel(labels, labelName) {
|
||||
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
|
||||
}
|
||||
|
||||
function isDependabotAuthor({ author, authorType }) {
|
||||
return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]';
|
||||
}
|
||||
|
||||
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
|
||||
return [
|
||||
`Thank you for your contribution, @${author}.`,
|
||||
@@ -83,7 +88,17 @@ async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }
|
||||
|
||||
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const { author, labels, number } = getPullRequest(context);
|
||||
const { author, authorType, labels, number } = getPullRequest(context);
|
||||
|
||||
if (isDependabotAuthor({ author, authorType })) {
|
||||
core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`);
|
||||
return {
|
||||
author,
|
||||
closed: false,
|
||||
dependabotExempt: true,
|
||||
openPrCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLabel(labels, exemptLabelName)) {
|
||||
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
|
||||
|
||||
@@ -16,7 +16,7 @@ const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createContext({ author = 'community-user', labels = [], number = 123 } = {}) {
|
||||
function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
|
||||
return {
|
||||
repo: {
|
||||
owner: 'microsoft',
|
||||
@@ -28,6 +28,7 @@ function createContext({ author = 'community-user', labels = [], number = 123 }
|
||||
labels: labels.map((name) => ({ name })),
|
||||
user: {
|
||||
login: author,
|
||||
type: authorType,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -296,6 +297,30 @@ describe('PR limit enforcement', () => {
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
it('does not close Dependabot PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
pullRequests: createPullRequestPage({
|
||||
author: 'dependabot[bot]',
|
||||
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, false);
|
||||
assert.equal(result.dependabotExempt, true);
|
||||
assert.equal(result.openPrCount, null);
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
it('counts the current PR when the author has more than one page of open PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)],
|
||||
|
||||
@@ -474,6 +474,45 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Integration Tests - GitHub Copilot
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -490,6 +529,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -553,7 +593,8 @@ jobs:
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
@@ -85,6 +86,8 @@ jobs:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
github_copilot:
|
||||
- 'python/packages/github_copilot/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -658,6 +661,58 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Tests - GitHub Copilot Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.githubCopilotChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: GitHub Copilot integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -674,6 +729,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -735,6 +791,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<!-- Hyperlight -->
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.8.0</VersionPrefix>
|
||||
<VersionPrefix>1.9.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260528</DateSuffix>
|
||||
<DateSuffix>260603</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.8.0</GitTag>
|
||||
<GitTag>1.9.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseHttpLogging();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ builder.Services.AddAGUI();
|
||||
// Configure to listen on port 8888
|
||||
builder.WebHost.UseUrls("http://localhost:8888");
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
[SendsMessage(typeof(List<ChatMessage>))]
|
||||
[SendsMessage(typeof(TurnToken))]
|
||||
private sealed partial class ConcurrentStartExecutor()
|
||||
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
[MessageHandler]
|
||||
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
|
||||
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
[MessageHandler]
|
||||
@@ -63,13 +67,16 @@ internal static partial class WorkflowHelper
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(List<ChatMessage>))]
|
||||
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed partial class ConcurrentAggregationExecutor() :
|
||||
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
|
||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
this._messages.Clear();
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseHttpLogging();
|
||||
|
||||
@@ -49,8 +49,9 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -12,6 +12,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
@@ -32,11 +34,19 @@ public static class ChatClientHarnessExtensions
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
/// </param>
|
||||
/// <param name="services">
|
||||
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
||||
public static HarnessAgent AsHarnessAgent(
|
||||
this IChatClient chatClient,
|
||||
int maxContextWindowTokens,
|
||||
int maxOutputTokens,
|
||||
HarnessAgentOptions? options = null) =>
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
||||
HarnessAgentOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null) =>
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -105,6 +106,12 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
/// </param>
|
||||
/// <param name="services">
|
||||
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
@@ -112,18 +119,20 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||
/// </exception>
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||
: base(BuildAgent(
|
||||
Throw.IfNull(chatClient),
|
||||
maxContextWindowTokens,
|
||||
maxOutputTokens,
|
||||
options))
|
||||
options,
|
||||
loggerFactory,
|
||||
services))
|
||||
{
|
||||
}
|
||||
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
|
||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||
|
||||
@@ -137,10 +146,10 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
return builder.Build(services);
|
||||
}
|
||||
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxContextWindowTokens,
|
||||
@@ -165,13 +174,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
||||
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy);
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
|
||||
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
|
||||
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||
: null)
|
||||
.UseMessageInjection()
|
||||
@@ -189,7 +198,9 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
},
|
||||
loggerFactory,
|
||||
services);
|
||||
}
|
||||
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
||||
@@ -215,7 +226,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
|
||||
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
var providers = new List<AIContextProvider>();
|
||||
|
||||
@@ -255,8 +266,8 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
if (options?.DisableAgentSkillsProvider is not true)
|
||||
{
|
||||
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
|
||||
? new AgentSkillsProvider(source)
|
||||
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
|
||||
? new AgentSkillsProvider(source, loggerFactory: loggerFactory)
|
||||
: new AgentSkillsProvider(Directory.GetCurrentDirectory(), loggerFactory: loggerFactory);
|
||||
|
||||
providers.Add(skillsProvider);
|
||||
}
|
||||
|
||||
+10
-1
@@ -103,7 +103,16 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
ArgumentNullException.ThrowIfNull(aiAgent);
|
||||
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new NoopAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore);
|
||||
|
||||
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
|
||||
+63
-6
@@ -27,6 +27,14 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
||||
{
|
||||
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of evaluated parameters at approval-request time.
|
||||
/// Used to prevent TOCTOU attacks where state mutates during the approval window.
|
||||
/// </summary>
|
||||
private ApprovalSnapshot? _approvalSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Step identifiers for the MCP tool invocation workflow.
|
||||
/// </summary>
|
||||
@@ -75,6 +83,10 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
if (requireApproval)
|
||||
{
|
||||
// Snapshot the evaluated parameters to prevent TOCTOU attacks.
|
||||
// If state mutates during the approval window, the approved values are used on resume.
|
||||
this._approvalSnapshot = new ApprovalSnapshot(serverUrl, serverLabel, toolName, arguments, connectionName);
|
||||
|
||||
// Create tool call content for approval request.
|
||||
// Transport headers (e.g. Authorization) are intentionally excluded from the
|
||||
// approval event: they must not cross into the externally-surfaced approval request.
|
||||
@@ -137,13 +149,14 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved - now invoke the tool
|
||||
string serverUrl = this.GetServerUrl();
|
||||
string? serverLabel = this.GetServerLabel();
|
||||
string toolName = this.GetToolName();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
// Approved - use the snapshot from approval-request time to prevent TOCTOU attacks.
|
||||
// Headers are re-evaluated (they may contain auth secrets that should not be persisted).
|
||||
string serverUrl = this._approvalSnapshot?.ServerUrl ?? this.GetServerUrl();
|
||||
string? serverLabel = this._approvalSnapshot?.ServerLabel ?? this.GetServerLabel();
|
||||
string toolName = this._approvalSnapshot?.ToolName ?? this.GetToolName();
|
||||
Dictionary<string, object?>? arguments = this._approvalSnapshot?.Arguments ?? this.GetArguments();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
string? connectionName = this._approvalSnapshot?.ConnectionName ?? this.GetConnectionName();
|
||||
|
||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||
serverUrl,
|
||||
@@ -162,9 +175,33 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
/// </summary>
|
||||
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Clear the approval snapshot after successful completion.
|
||||
this._approvalSnapshot = null;
|
||||
await ClearSnapshotStateAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the approval snapshot from workflow state after a checkpoint restore.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
||||
{
|
||||
bool autoSend = this.GetAutoSendValue();
|
||||
@@ -365,4 +402,24 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the persisted approval snapshot state after a successful tool invocation.
|
||||
/// </summary>
|
||||
private static async ValueTask ClearSnapshotStateAsync(IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the evaluated parameters at approval-request time so that
|
||||
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
|
||||
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalSnapshot(
|
||||
string ServerUrl,
|
||||
string? ServerLabel,
|
||||
string ToolName,
|
||||
Dictionary<string, object?>? Arguments,
|
||||
string? ConnectionName);
|
||||
}
|
||||
|
||||
@@ -49,14 +49,13 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content. When the skill has scripts, a
|
||||
/// <c><scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts></c>
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// <c><script_schemas></c> block is appended describing the argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ public abstract class AgentClassSkill<
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
@@ -147,11 +146,17 @@ public abstract class AgentClassSkill<
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation returns resources discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
|
||||
@@ -159,11 +164,17 @@ public abstract class AgentClassSkill<
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation returns scripts discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only script parameter schemas are included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
|
||||
@@ -184,6 +195,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill resource backed by a static value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -194,6 +209,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill resource backed by a delegate that produces a dynamic value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -208,6 +227,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill script backed by a delegate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -115,6 +115,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -129,6 +133,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a dynamic resource with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -147,6 +155,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a script with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
|
||||
+13
-41
@@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI;
|
||||
internal static class AgentInlineSkillContentBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete skill content containing name, description, instructions, resources, and scripts.
|
||||
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
|
||||
/// </summary>
|
||||
/// <param name="name">The skill name.</param>
|
||||
/// <param name="description">The skill description.</param>
|
||||
/// <param name="instructions">The raw instructions text.</param>
|
||||
/// <param name="resources">Optional resources associated with the skill.</param>
|
||||
/// <param name="scripts">Optional scripts associated with the skill.</param>
|
||||
/// <returns>An XML-structured content string.</returns>
|
||||
public static string Build(
|
||||
string name,
|
||||
string description,
|
||||
string instructions,
|
||||
IReadOnlyList<AgentSkillResource>? resources,
|
||||
IReadOnlyList<AgentSkillScript>? scripts)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(name);
|
||||
@@ -39,41 +37,24 @@ internal static class AgentInlineSkillContentBuilder
|
||||
.Append(EscapeXmlString(instructions))
|
||||
.Append("\n</instructions>");
|
||||
|
||||
if (resources is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<resources>\n");
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
if (resource.Description is not null)
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</resources>");
|
||||
}
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptsBlock(scripts));
|
||||
sb.Append(BuildScriptSchemasBlock(scripts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c><scripts>...</scripts></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><script name="..."></c> element with optional
|
||||
/// <c>description</c> attribute and <c><parameters_schema></c> child element.
|
||||
/// Builds a <c><script_schemas>...</script_schemas></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><schema script="..."></c> element containing only
|
||||
/// the parameter schema. This block serves as a reference for the model to know how to
|
||||
/// format arguments when calling scripts, not as a discovery mechanism.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <returns>An XML string starting with <c>\n<scripts></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
/// <returns>An XML string starting with <c>\n<script_schemas></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
@@ -83,32 +64,23 @@ internal static class AgentInlineSkillContentBuilder
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<scripts>\n");
|
||||
sb.Append("\n<script_schemas>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
if (parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
sb.Append("</script_schemas>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
@@ -1460,4 +1461,131 @@ public class HarnessAgentTests
|
||||
|
||||
#endregion
|
||||
#endif
|
||||
|
||||
#region LoggerFactory and ServiceProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when loggerFactory is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithLoggerFactory()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when serviceProvider is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when both loggerFactory and serviceProvider are provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithLoggerFactoryAndServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsHarnessAgent extension method accepts loggerFactory and serviceProvider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsHarnessAgent_SucceedsWithLoggerFactoryAndServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ILoggerFactory is threaded to downstream components by confirming CreateLogger is called.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_LoggerFactoryIsUsedByDownstreamComponents()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var mockLoggerFactory = new Mock<ILoggerFactory>();
|
||||
mockLoggerFactory
|
||||
.Setup(lf => lf.CreateLogger(It.IsAny<string>()))
|
||||
.Returns(new Mock<ILogger>().Object);
|
||||
|
||||
// Act — use options that leave CompactionProvider and AgentSkillsProvider enabled
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
};
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options, mockLoggerFactory.Object);
|
||||
|
||||
// Assert — CreateLogger should have been called by one or more downstream components
|
||||
Assert.NotNull(agent);
|
||||
mockLoggerFactory.Verify(lf => lf.CreateLogger(It.IsAny<string>()), Times.AtLeastOnce());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that IServiceProvider is propagated through the agent pipeline by confirming
|
||||
/// it is queried during agent construction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ServiceProviderIsQueriedDuringBuild()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var mockServices = new Mock<IServiceProvider>();
|
||||
mockServices
|
||||
.Setup(sp => sp.GetService(It.IsAny<Type>()))
|
||||
.Returns(null!);
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: mockServices.Object);
|
||||
|
||||
// Assert — the service provider should have been queried during pipeline construction
|
||||
Assert.NotNull(agent);
|
||||
mockServices.Verify(sp => sp.GetService(It.IsAny<Type>()), Times.AtLeastOnce());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -51,9 +51,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — Content is cached
|
||||
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — Content includes parameter schema from typed script
|
||||
Assert.Contains("parameters_schema", await skill.GetContentAsync());
|
||||
Assert.Contains("value", await skill.GetContentAsync());
|
||||
// Act & Assert — Content includes parameter schema from typed script (with preserved quotes)
|
||||
Assert.Contains("\"value\"", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -383,10 +382,9 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes reflected resources and scripts
|
||||
Assert.Contains("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<scripts>", await skill.GetContentAsync());
|
||||
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
|
||||
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
@@ -504,7 +502,7 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
|
||||
public async Task Content_DoesNotRenderResources_InBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
@@ -512,8 +510,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — descriptions from [Description] attribute appear in synthesized content
|
||||
Assert.Contains("Some important data.", content);
|
||||
// Assert — resources are no longer rendered in body content
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -122,11 +122,10 @@ public sealed class AgentFileSkillScriptTests
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script name=\"build\">", content);
|
||||
Assert.Contains("<script name=\"deploy\">", content);
|
||||
Assert.Contains("<parameters_schema>", content);
|
||||
Assert.Contains("</scripts>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("<schema script=\"build\">", content);
|
||||
Assert.Contains("<schema script=\"deploy\">", content);
|
||||
Assert.Contains("</script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -158,13 +158,12 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("config", content);
|
||||
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -173,9 +172,8 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("dynamic", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -189,7 +187,7 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("run", content);
|
||||
}
|
||||
|
||||
@@ -209,7 +207,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_IncludesScriptSchemasAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -220,9 +218,8 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("r1", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("s1", content);
|
||||
}
|
||||
|
||||
@@ -236,8 +233,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present and XML content chars escaped
|
||||
Assert.Contains("parameters_schema", content);
|
||||
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
|
||||
Assert.Contains("<schema script=\"search\">", content);
|
||||
Assert.Contains("\"query\"", content);
|
||||
Assert.DoesNotContain("<![CDATA[", content);
|
||||
}
|
||||
|
||||
@@ -429,7 +427,7 @@ public sealed class AgentInlineSkillTests
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("<scripts>", content);
|
||||
Assert.DoesNotContain("<script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -463,7 +461,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public async Task Content_ScriptWithDescription_DoesNotEmitDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -472,8 +470,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"Runs something.\"", content);
|
||||
// Assert — description is no longer emitted in the script_schemas block;
|
||||
// the block only contains parameter schemas for calling scripts.
|
||||
Assert.Contains("<schema script=\"my-script\"", content);
|
||||
Assert.DoesNotContain("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -492,7 +492,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -502,9 +502,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"A described resource.\"", content);
|
||||
Assert.DoesNotContain("no-desc\" description", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("with-desc", content);
|
||||
Assert.DoesNotContain("no-desc", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+354
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
@@ -11,7 +12,9 @@ using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeMcpToolExecutor.ApprovalSnapshot;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
@@ -842,6 +845,313 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approval Snapshot Security Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating the tool name variable after approval does not change
|
||||
/// which tool is actually invoked. The originally-approved tool name must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesApprovedToolNameNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedToolName = "safe_readonly_query";
|
||||
const string MutatedToolName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetTool", FormulaValue.New(ApprovedToolName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableToolName(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesApprovedToolNameNotMutatedAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
variableName: "TargetTool");
|
||||
|
||||
string? capturedToolName = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("TargetTool", FormulaValue.New(MutatedToolName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve (they saw "safe_readonly_query" in the approval UI)
|
||||
McpServerToolCallContent toolCall = new(action.Id, ApprovedToolName, TestServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved tool name must be used, not the mutated one
|
||||
Assert.NotNull(capturedToolName);
|
||||
Assert.Equal(ApprovedToolName, capturedToolName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating an argument variable after approval does not change
|
||||
/// the arguments actually passed to the MCP tool. The originally-approved arguments must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedQuery = "SELECT * FROM users LIMIT 10";
|
||||
const string MutatedQuery = "DROP TABLE users CASCADE; --";
|
||||
|
||||
this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableArgument(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
argumentKey: "query",
|
||||
variableName: "SqlQuery");
|
||||
|
||||
IDictionary<string, object?>? capturedArguments = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, _, arguments, _, _, _) => capturedArguments = arguments)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved argument must be used, not the mutated one
|
||||
Assert.NotNull(capturedArguments);
|
||||
Assert.Equal(ApprovedQuery, capturedArguments["query"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating the server URL variable after approval does not redirect
|
||||
/// the MCP tool call to a different server. The originally-approved server URL must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesApprovedServerUrlNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedServerUrl = "https://internal-mcp.corp";
|
||||
const string MutatedServerUrl = "https://attacker.evil/steal";
|
||||
|
||||
this.State.Set("McpEndpoint", FormulaValue.New(ApprovedServerUrl));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableServerUrl(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesApprovedServerUrlNotMutatedAsync),
|
||||
variableName: "McpEndpoint",
|
||||
toolName: TestToolName);
|
||||
|
||||
string? capturedServerUrl = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(serverUrl, _, _, _, _, _, _) => capturedServerUrl = serverUrl)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("McpEndpoint", FormulaValue.New(MutatedServerUrl));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, ApprovedServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved server URL must be used, not the mutated one
|
||||
Assert.NotNull(capturedServerUrl);
|
||||
Assert.Equal(ApprovedServerUrl, capturedServerUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the approval snapshot survives a checkpoint/restore cycle.
|
||||
/// After restore, the originally-approved tool name must still be used even if state was mutated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedToolName = "safe_readonly_query";
|
||||
const string MutatedToolName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetTool", FormulaValue.New(ApprovedToolName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableToolName(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
variableName: "TargetTool");
|
||||
|
||||
string? capturedToolName = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate checkpoint: persist to state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate restore on a "new" executor instance by clearing the in-memory field via reflection
|
||||
// (In production, a new executor instance would be created with _approvalSnapshot == null)
|
||||
typeof(InvokeMcpToolExecutor)
|
||||
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(action, null);
|
||||
|
||||
// Restore from state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Mutate state after restore (simulating parallel branch)
|
||||
this.State.Set("TargetTool", FormulaValue.New(MutatedToolName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
McpServerToolCallContent toolCall = new(action.Id, ApprovedToolName, TestServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved tool name must be used, not the mutated one
|
||||
Assert.NotNull(capturedToolName);
|
||||
Assert.Equal(ApprovedToolName, capturedToolName);
|
||||
}
|
||||
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContext()
|
||||
{
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests).
|
||||
/// </summary>
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContextWithStateStore()
|
||||
{
|
||||
Dictionary<string, object?> stateStore = new();
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<ApprovalSnapshot?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, ApprovalSnapshot?, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value)
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.ReadStateAsync<ApprovalSnapshot>(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string?, CancellationToken>((key, _, _) =>
|
||||
new ValueTask<ApprovalSnapshot?>(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null));
|
||||
mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<string>());
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a protected method on an executor via reflection (for testing checkpoint hooks).
|
||||
/// </summary>
|
||||
private static async ValueTask InvokeProtectedMethodAsync(InvokeMcpToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
MethodInfo method = typeof(InvokeMcpToolExecutor)
|
||||
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!;
|
||||
await result.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CompleteAsync Tests
|
||||
|
||||
[Fact]
|
||||
@@ -951,6 +1261,50 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModelWithVariableToolName(string displayName, string serverUrl, string variableName)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)),
|
||||
ToolName = new StringExpression.Builder(
|
||||
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModelWithVariableArgument(
|
||||
string displayName, string serverUrl, string toolName, string argumentKey, string variableName)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)),
|
||||
ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
builder.Arguments.Add(argumentKey,
|
||||
ValueExpression.Variable(PropertyPath.TopicVariable(variableName)));
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModelWithVariableServerUrl(string displayName, string variableName, string toolName)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(
|
||||
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
|
||||
ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mock MCP Tool Provider
|
||||
|
||||
+39
-1
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.8.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add MCP-based skills discovery (`McpSkillsSource`) ([#6169](https://github.com/microsoft/agent-framework/pull/6169))
|
||||
- **agent-framework-core**: Progressive tool exposure via `FunctionInvocationContext` ([#6233](https://github.com/microsoft/agent-framework/pull/6233))
|
||||
- **agent-framework-core**: Add background agent support to harness agent ([#6155](https://github.com/microsoft/agent-framework/pull/6155))
|
||||
- **agent-framework-core**: Add `AgentFileStore` and `FileAccessProvider` for file access operations ([#6099](https://github.com/microsoft/agent-framework/pull/6099))
|
||||
- **agent-framework-core**: Coalesce code interpreter history chunks ([#5801](https://github.com/microsoft/agent-framework/pull/5801))
|
||||
- **agent-framework-core**: Run sync tools off the event loop ([#5773](https://github.com/microsoft/agent-framework/pull/5773))
|
||||
- **agent-framework-bedrock**: Implement native structured output support via Converse API ([#6052](https://github.com/microsoft/agent-framework/pull/6052))
|
||||
- **agent-framework-foundry**: Add Foundry Adaptive Evals integration for rubric-generation ([#6101](https://github.com/microsoft/agent-framework/pull/6101))
|
||||
- **agent-framework-foundry**: Add `timeout` parameter to `FoundryAgent` to fix `ConnectTimeout` on multi-turn conversations ([#6263](https://github.com/microsoft/agent-framework/pull/6263))
|
||||
- **agent-framework-mistral**: Add Mistral AI embedding client package ([#5480](https://github.com/microsoft/agent-framework/pull/5480))
|
||||
- **agent-framework-a2a**: Expose `supported_protocol_bindings` as configurable parameter ([#6098](https://github.com/microsoft/agent-framework/pull/6098))
|
||||
- **agent-framework-a2a**: Set `message_id` on `AgentResponseUpdate` for message-bearing paths ([#6163](https://github.com/microsoft/agent-framework/pull/6163))
|
||||
- **agent-framework-foundry-hosting**: Persist hosted MCP call/results as canonical `mcp_call` output ([#6070](https://github.com/microsoft/agent-framework/pull/6070))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-github-copilot**: [BREAKING] Upgrade `github-copilot-sdk` to v1.0.0 (stable) ([#6292](https://github.com/microsoft/agent-framework/pull/6292))
|
||||
- **agent-framework-core**: [BREAKING — experimental] Refactor Skill API to async resource and script lookup ([#6135](https://github.com/microsoft/agent-framework/pull/6135))
|
||||
- **agent-framework-github-copilot**: Promote to release candidate (`1.0.0rc1`)
|
||||
- **agent-framework-declarative**: Promote to release candidate (`1.0.0rc1`) ([#6256](https://github.com/microsoft/agent-framework/pull/6256))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Fix compaction message-id collisions and tool-loop summary persistence ([#6299](https://github.com/microsoft/agent-framework/pull/6299))
|
||||
- **agent-framework-core**: Fix observability unsafe serialization of function-call arguments containing dataclass/framework objects ([#6026](https://github.com/microsoft/agent-framework/pull/6026))
|
||||
- **agent-framework-core**: Consolidate MCP reliability fixes ([#6145](https://github.com/microsoft/agent-framework/pull/6145))
|
||||
- **agent-framework-core**: Backfill chat span request model if unknown and response model is available ([#6160](https://github.com/microsoft/agent-framework/pull/6160))
|
||||
- **agent-framework-anthropic**: Skip orphan anthropic thinking signatures ([#5784](https://github.com/microsoft/agent-framework/pull/5784))
|
||||
- **agent-framework-foundry**: Fix `FoundryAgent` stripping model from `PromptAgent` requests ([#5526](https://github.com/microsoft/agent-framework/pull/5526))
|
||||
- **agent-framework-foundry-hosting**: Fix toolbox consent flow in hosted agent ([#6249](https://github.com/microsoft/agent-framework/pull/6249))
|
||||
- **agent-framework-foundry-hosting**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
|
||||
- **agent-framework-openai**: Fix OTLP HTTP base-endpoint losing `/v1/{signal}` auto-append ([#5913](https://github.com/microsoft/agent-framework/pull/5913))
|
||||
- **agent-framework-openai**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
|
||||
- **agent-framework-orchestrations**: Fix spurious Magentic custom manager warning ([#6261](https://github.com/microsoft/agent-framework/pull/6261))
|
||||
- **agent-framework-azurefunctions**: Fix integration test worker crashes on Py3.13 ([#4260](https://github.com/microsoft/agent-framework/pull/4260))
|
||||
|
||||
## [1.7.0] - 2026-05-28
|
||||
|
||||
### Added
|
||||
@@ -1132,7 +1169,8 @@ 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.7.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...HEAD
|
||||
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
|
||||
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
|
||||
@@ -33,7 +33,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
|
||||
@@ -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.0b260528"
|
||||
version = "1.0.0b260604"
|
||||
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.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260604"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,8 +22,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260521,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260604,<2",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
]
|
||||
|
||||
@@ -795,10 +795,7 @@ class BedrockChatClient(
|
||||
schema = copy.deepcopy(schema_src)
|
||||
else:
|
||||
if not isinstance(response_format, type) or not issubclass(response_format, BaseModel):
|
||||
raise TypeError(
|
||||
"response_format must be None, a dict JSON schema, "
|
||||
"or a Pydantic BaseModel subclass."
|
||||
)
|
||||
raise TypeError("response_format must be None, a dict JSON schema, or a Pydantic BaseModel subclass.")
|
||||
# response_format is a Pydantic model class
|
||||
schema = response_format.model_json_schema()
|
||||
name = response_format.__name__
|
||||
@@ -817,9 +814,7 @@ class BedrockChatClient(
|
||||
return {
|
||||
"textFormat": {
|
||||
"type": "json_schema",
|
||||
"structure": {
|
||||
"jsonSchema": json_schema
|
||||
},
|
||||
"structure": {"jsonSchema": json_schema},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,9 +835,7 @@ class BedrockChatClient(
|
||||
if node_id in visited:
|
||||
return
|
||||
visited.add(node_id)
|
||||
if node.get("type") == "object" or (
|
||||
"properties" in node and "type" not in node
|
||||
):
|
||||
if node.get("type") == "object" or ("properties" in node and "type" not in node):
|
||||
existing = node.get("additionalProperties")
|
||||
if existing is None or existing is True:
|
||||
node["additionalProperties"] = False
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260604"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -238,6 +238,7 @@ async def test_chat_response_value_populated_streaming() -> None:
|
||||
|
||||
async def test_unsupported_model_validation_exception() -> None:
|
||||
"""When a model doesn't support outputConfig, a clear error should be raised."""
|
||||
|
||||
class _FailingStubBedrockRuntime:
|
||||
def converse(self, **kwargs: Any) -> dict[str, Any]:
|
||||
# Simulate botocore ClientError for ValidationException
|
||||
|
||||
@@ -56,7 +56,7 @@ agent_framework/
|
||||
- **`AgentMiddleware`** - Intercepts agent `run()` calls
|
||||
- **`ChatMiddleware`** - Intercepts chat client `get_response()` calls
|
||||
- **`FunctionMiddleware`** - Intercepts function/tool invocations
|
||||
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware
|
||||
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration).
|
||||
|
||||
### Sessions (`_sessions.py`)
|
||||
|
||||
|
||||
@@ -168,6 +168,9 @@ from ._skills import (
|
||||
InlineSkillResource,
|
||||
InlineSkillScript,
|
||||
InMemorySkillsSource,
|
||||
MCPSkill,
|
||||
MCPSkillResource,
|
||||
MCPSkillsSource,
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
SkillResource,
|
||||
@@ -444,6 +447,9 @@ __all__ = [
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPWebsocketTool",
|
||||
"MCPSkill",
|
||||
"MCPSkillResource",
|
||||
"MCPSkillsSource",
|
||||
"MemoryContextProvider",
|
||||
"MemoryFileStore",
|
||||
"MemoryIndexEntry",
|
||||
|
||||
@@ -380,8 +380,15 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
|
||||
return prepared_messages
|
||||
from ._compaction import apply_compaction
|
||||
|
||||
# Compact the caller's list in place when possible. A compaction operation has
|
||||
# two halves: exclusion flags (mutated on shared Message objects) and inserted
|
||||
# summary messages. Operating on the original list keeps both halves on the list
|
||||
# the function-invocation tool loop reuses across iterations; otherwise inserted
|
||||
# summaries would be lost on a throwaway copy while exclusions persisted, silently
|
||||
# dropping older groups (issue #4991).
|
||||
working_messages = messages if isinstance(messages, list) else prepared_messages
|
||||
return await apply_compaction(
|
||||
prepared_messages,
|
||||
working_messages,
|
||||
strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
@@ -92,10 +92,23 @@ def _is_reasoning_only_assistant(message: Message) -> bool:
|
||||
return all(content.type == "text_reasoning" for content in message.contents)
|
||||
|
||||
|
||||
def _ensure_message_ids(messages: list[Message]) -> None:
|
||||
def _ensure_message_ids(
|
||||
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
|
||||
) -> None:
|
||||
existing_ids: set[str] = set(reserved_ids) if reserved_ids is not None else set()
|
||||
existing_ids.update(message.message_id for message in messages if message.message_id)
|
||||
for index, message in enumerate(messages):
|
||||
if not message.message_id:
|
||||
message.message_id = f"msg_{index}"
|
||||
if message.message_id:
|
||||
continue
|
||||
candidate = f"msg_{id_offset + index}"
|
||||
if candidate in existing_ids:
|
||||
counter = id_offset + len(messages)
|
||||
candidate = f"msg_{counter}"
|
||||
while candidate in existing_ids:
|
||||
counter += 1
|
||||
candidate = f"msg_{counter}"
|
||||
message.message_id = candidate
|
||||
existing_ids.add(candidate)
|
||||
|
||||
|
||||
def _group_id_for(message: Message, group_index: int) -> str:
|
||||
@@ -104,14 +117,27 @@ def _group_id_for(message: Message, group_index: int) -> str:
|
||||
return f"group_index_{group_index}"
|
||||
|
||||
|
||||
def group_messages(messages: list[Message]) -> list[dict[str, Any]]:
|
||||
def group_messages(
|
||||
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compute group spans and metadata for annotation.
|
||||
|
||||
Args:
|
||||
messages: The messages (or a slice of them) to group.
|
||||
|
||||
Keyword Args:
|
||||
id_offset: Absolute starting index used when auto-assigning ``message_id``
|
||||
values, so incremental annotation of a list slice produces ids that
|
||||
stay unique across the full list.
|
||||
reserved_ids: Message ids that already exist outside ``messages`` (for
|
||||
example in a preserved prefix). Auto-assigned ids are guaranteed not
|
||||
to collide with these, preventing duplicate ids across the full list.
|
||||
|
||||
Returns:
|
||||
Ordered list of lightweight span dicts with keys:
|
||||
``group_id``, ``kind``, ``start_index``, ``end_index``, ``has_reasoning``.
|
||||
"""
|
||||
_ensure_message_ids(messages)
|
||||
_ensure_message_ids(messages, id_offset=id_offset, reserved_ids=reserved_ids)
|
||||
spans: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
group_index = 0
|
||||
@@ -439,7 +465,8 @@ def annotate_message_groups(
|
||||
if previous_group_index is not None:
|
||||
group_index_offset = previous_group_index + 1
|
||||
|
||||
spans = group_messages(messages[start_index:])
|
||||
reserved_ids = {message.message_id for message in messages[:start_index] if message.message_id}
|
||||
spans = group_messages(messages[start_index:], id_offset=start_index, reserved_ids=reserved_ids)
|
||||
for span_index, span in enumerate(spans):
|
||||
group_id = str(span["group_id"])
|
||||
kind = _coerce_group_kind(span["kind"])
|
||||
|
||||
@@ -58,6 +58,8 @@ class ExperimentalFeature(str, Enum):
|
||||
FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS"
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
MCP_SKILLS = "MCP_SKILLS"
|
||||
PROGRESSIVE_TOOLS = "PROGRESSIVE_TOOLS"
|
||||
SKILLS = "SKILLS"
|
||||
TO_PROMPT_AGENT = "TO_PROMPT_AGENT"
|
||||
|
||||
|
||||
@@ -349,6 +349,8 @@ class BackgroundAgentsProvider(ContextProvider):
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Background task {task_id} started on agent '{agent_name}'."
|
||||
|
||||
background_agents_start_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
|
||||
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
|
||||
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
|
||||
@@ -471,6 +473,8 @@ class BackgroundAgentsProvider(ContextProvider):
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Task {task_id} continued with new input."
|
||||
|
||||
background_agents_continue_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@tool(name="background_agents_clear_completed_task", approval_mode="never_require")
|
||||
def background_agents_clear_completed_task(task_id: int) -> str:
|
||||
"""Remove a completed or failed task and release its session to free memory."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast, overload
|
||||
|
||||
from ._clients import SupportsChatGetResponse
|
||||
from ._feature_stage import ExperimentalFeature, experimental
|
||||
from ._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
@@ -214,6 +215,12 @@ class FunctionInvocationContext:
|
||||
result: Function execution result. Can be observed after calling ``call_next()``
|
||||
to see the actual execution result or can be set to override the execution result.
|
||||
kwargs: Additional runtime keyword arguments forwarded to the function invocation.
|
||||
tools: The live, mutable list of tools available to the model for the current
|
||||
agent run, or ``None`` when the function is invoked outside of a
|
||||
function-calling loop (for example via ``FunctionTool.invoke`` directly).
|
||||
Tools can add or remove tools during execution using :meth:`add_tools`
|
||||
and :meth:`remove_tools` (progressive tool exposure). Mutations take
|
||||
effect on the **next** model iteration, not the in-flight batch.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -232,6 +239,18 @@ class FunctionInvocationContext:
|
||||
|
||||
# Continue execution
|
||||
await call_next()
|
||||
|
||||
Progressive tool exposure from inside a tool:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import FunctionInvocationContext, tool
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def load_math_tools(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools([factorial, fibonacci])
|
||||
return "Math tools are now available."
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -242,6 +261,7 @@ class FunctionInvocationContext:
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
result: Any = None,
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
tools: list[ToolTypes] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the FunctionInvocationContext.
|
||||
|
||||
@@ -252,6 +272,9 @@ class FunctionInvocationContext:
|
||||
metadata: Metadata dictionary for sharing data between function middleware.
|
||||
result: Function execution result.
|
||||
kwargs: Additional runtime keyword arguments forwarded to the function invocation.
|
||||
tools: The live, mutable list of tools for the current agent run. When provided,
|
||||
this is the same list object the model sees on the next iteration, so
|
||||
appending or removing tools changes the model's available tools.
|
||||
"""
|
||||
self.function = function
|
||||
self.arguments = arguments
|
||||
@@ -259,6 +282,96 @@ class FunctionInvocationContext:
|
||||
self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {}
|
||||
self.result = result
|
||||
self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {}
|
||||
self.tools = tools
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.PROGRESSIVE_TOOLS)
|
||||
def add_tools(
|
||||
self,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Add one or more tools to the current agent run (progressive tool exposure).
|
||||
|
||||
Callable inputs are converted to :class:`FunctionTool`, and tool collections are
|
||||
flattened, using the same normalization as the rest of the framework. Added tools
|
||||
become available to the model on the **next** iteration of the function-calling
|
||||
loop; they do not affect tool calls already requested in the in-flight batch.
|
||||
|
||||
Adding a tool whose name already exists is a no-op when it is the same object, and
|
||||
raises ``ValueError`` when it is a different object with a duplicate name.
|
||||
|
||||
Args:
|
||||
tools: A single tool/callable or a sequence of tools/callables to add.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the context has no live tools list (for example when the
|
||||
function is invoked outside of a function-calling loop).
|
||||
ValueError: If a different tool with a duplicate name is added.
|
||||
"""
|
||||
from ._tools import _append_unique_tools, normalize_tools # type: ignore[reportPrivateUsage]
|
||||
|
||||
if self.tools is None:
|
||||
raise RuntimeError(
|
||||
"Cannot add tools: this FunctionInvocationContext is not bound to a live "
|
||||
"agent run. add_tools is only available for functions invoked within an "
|
||||
"agent's function-calling loop."
|
||||
)
|
||||
# Validate the whole batch against a throwaway copy first, so a duplicate-name
|
||||
# clash partway through the batch raises before the live tool list is mutated
|
||||
# (all-or-nothing semantics).
|
||||
merged = _append_unique_tools(list(self.tools), normalize_tools(tools))
|
||||
self.tools[:] = merged
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.PROGRESSIVE_TOOLS)
|
||||
def remove_tools(
|
||||
self,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | str | Sequence[str],
|
||||
) -> None:
|
||||
"""Remove one or more tools from the current agent run (progressive tool exposure).
|
||||
|
||||
Tools may be specified by name, by tool object, or by the original callable. Names
|
||||
that are not currently present are ignored. Removals take effect on the **next**
|
||||
iteration of the function-calling loop; tool calls already requested in the
|
||||
in-flight batch still execute.
|
||||
|
||||
Args:
|
||||
tools: A tool name, tool/callable, or a sequence of any of these to remove.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the context has no live tools list (for example when the
|
||||
function is invoked outside of a function-calling loop).
|
||||
"""
|
||||
from ._tools import _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage]
|
||||
|
||||
if self.tools is None:
|
||||
raise RuntimeError(
|
||||
"Cannot remove tools: this FunctionInvocationContext is not bound to a live "
|
||||
"agent run. remove_tools is only available for functions invoked within an "
|
||||
"agent's function-calling loop."
|
||||
)
|
||||
|
||||
names_to_remove: set[str] = set()
|
||||
raw_items: list[Any]
|
||||
if isinstance(tools, str):
|
||||
raw_items = [tools]
|
||||
elif isinstance(tools, Sequence) and not isinstance(tools, (bytes, bytearray)):
|
||||
raw_items = list(cast("Sequence[Any]", tools))
|
||||
else:
|
||||
raw_items = [tools]
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
names_to_remove.add(item)
|
||||
continue
|
||||
for normalized in normalize_tools(item):
|
||||
if name := _get_tool_name(normalized): # type: ignore[reportPrivateUsage]
|
||||
names_to_remove.add(name)
|
||||
|
||||
if not names_to_remove:
|
||||
return
|
||||
self.tools[:] = [
|
||||
tool
|
||||
for tool in self.tools
|
||||
if _get_tool_name(tool) not in names_to_remove # type: ignore[reportPrivateUsage]
|
||||
]
|
||||
|
||||
|
||||
class ChatContext:
|
||||
|
||||
@@ -44,6 +44,7 @@ Only use skills from trusted sources.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -60,6 +61,10 @@ from ._sessions import ContextProvider
|
||||
from ._tools import FunctionTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.types import ReadResourceResult
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from ._agents import SupportsAgentRun
|
||||
from ._sessions import AgentSession, SessionContext
|
||||
|
||||
@@ -3285,4 +3290,443 @@ class AggregatingSkillsSource(SkillsSource):
|
||||
return result
|
||||
|
||||
|
||||
# region MCP Skills
|
||||
|
||||
|
||||
def _mcp_any_url(uri: str) -> AnyUrl:
|
||||
"""Convert a string URI to a :class:`pydantic.AnyUrl` for MCP client calls."""
|
||||
from pydantic import AnyUrl as _AnyUrl
|
||||
|
||||
return _AnyUrl(uri)
|
||||
|
||||
|
||||
def _is_mcp_resource_not_found(ex: Exception) -> bool:
|
||||
"""Return ``True`` when *ex* is an :class:`McpError` indicating a missing resource.
|
||||
|
||||
Two codes are treated as "not found":
|
||||
|
||||
* ``-32002`` — the MCP-spec "Resource not found" code returned by a
|
||||
compliant server when the URI does not exist. Not exported as a
|
||||
constant from ``mcp.types`` but defined by the resources subprotocol.
|
||||
* ``METHOD_NOT_FOUND`` (``-32601``) — the server does not implement
|
||||
``resources/read`` at all, which for the skills source is functionally
|
||||
equivalent to "no skills available."
|
||||
|
||||
All other codes — ``INVALID_PARAMS``, ``INTERNAL_ERROR``, ``PARSE_ERROR``,
|
||||
``CONNECTION_CLOSED``, auth rejections, and generic handler errors
|
||||
(code ``0``) — are treated as real failures so that a misconfigured
|
||||
token or crashing server is not silently mistaken for "the server has no
|
||||
skills."
|
||||
"""
|
||||
from mcp.shared.exceptions import McpError as _McpError
|
||||
|
||||
if not isinstance(ex, _McpError):
|
||||
return False
|
||||
from mcp.types import METHOD_NOT_FOUND as _METHOD_NOT_FOUND
|
||||
|
||||
return ex.error.code in {-32002, _METHOD_NOT_FOUND}
|
||||
|
||||
|
||||
def _mcp_join_text(result: ReadResourceResult) -> str:
|
||||
"""Join all :class:`TextResourceContents` items in a result into a single string."""
|
||||
from mcp.types import TextResourceContents as _TextResourceContents
|
||||
|
||||
return "\n".join(c.text for c in result.contents if isinstance(c, _TextResourceContents))
|
||||
|
||||
|
||||
class _McpSkillIndexEntry: # noqa: B903
|
||||
"""A single entry in the ``skill://index.json`` discovery document.
|
||||
|
||||
All fields are optional to support lenient deserialization; callers
|
||||
validate required fields before use.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
type: str | None = None,
|
||||
description: str | None = None,
|
||||
url: str | None = None,
|
||||
digest: str | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.url = url
|
||||
self.digest = digest
|
||||
|
||||
|
||||
class _McpSkillIndex:
|
||||
"""DTO for the ``skill://index.json`` discovery document.
|
||||
|
||||
Represents the Agent Skills Discovery v0.2.0 schema as bound to MCP
|
||||
by SEP-2640.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
schema: str | None = None,
|
||||
skills: list[_McpSkillIndexEntry] | None = None,
|
||||
) -> None:
|
||||
self.schema = schema
|
||||
self.skills: list[_McpSkillIndexEntry] = skills if skills is not None else []
|
||||
|
||||
|
||||
def _parse_mcp_skill_index(text: str) -> _McpSkillIndex:
|
||||
"""Parse a JSON string into a :class:`_McpSkillIndex`.
|
||||
|
||||
Args:
|
||||
text: Raw JSON text from ``skill://index.json``.
|
||||
|
||||
Returns:
|
||||
A populated :class:`_McpSkillIndex` instance.
|
||||
|
||||
Raises:
|
||||
json.JSONDecodeError: If the text is not valid JSON.
|
||||
ValueError: If the top-level value is not a JSON object.
|
||||
"""
|
||||
raw: dict[str, Any] = json.loads(text)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("skill://index.json must be a JSON object")
|
||||
|
||||
entries: list[_McpSkillIndexEntry] = []
|
||||
|
||||
raw_skills: list[Any] = raw.get("skills") or []
|
||||
|
||||
for item in raw_skills:
|
||||
if isinstance(item, dict):
|
||||
d = cast(dict[str, Any], item)
|
||||
|
||||
entries.append(
|
||||
_McpSkillIndexEntry(
|
||||
name=d.get("name"),
|
||||
type=d.get("type"),
|
||||
description=d.get("description"),
|
||||
url=d.get("url"),
|
||||
digest=d.get("digest"),
|
||||
)
|
||||
)
|
||||
|
||||
return _McpSkillIndex(schema=raw.get("$schema"), skills=entries)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
|
||||
class MCPSkillResource(SkillResource):
|
||||
"""A :class:`SkillResource` backed by content fetched from an MCP server.
|
||||
|
||||
The :class:`~mcp.types.ReadResourceResult` is fetched eagerly by
|
||||
:meth:`MCPSkill.get_resource` at construction time; :meth:`read`
|
||||
extracts text or binary content from the result.
|
||||
"""
|
||||
|
||||
def __init__(self, *, name: str, result: ReadResourceResult) -> None:
|
||||
"""Initialize an MCPSkillResource.
|
||||
|
||||
Args:
|
||||
name: The resource name (e.g. a relative path or identifier).
|
||||
result: The result returned by the MCP server's ``resources/read`` request.
|
||||
"""
|
||||
super().__init__(name=name)
|
||||
self._result = result
|
||||
|
||||
async def read(self, **kwargs: Any) -> Any:
|
||||
"""Read the resource content.
|
||||
|
||||
Returns:
|
||||
A ``bytes`` object when the resource contains binary content,
|
||||
a ``str`` when it contains text, or ``None`` when the server
|
||||
returned no content blocks.
|
||||
"""
|
||||
from mcp.types import BlobResourceContents, TextResourceContents
|
||||
|
||||
for content in self._result.contents:
|
||||
if isinstance(content, BlobResourceContents):
|
||||
blob = content.blob
|
||||
# Strip data-URI prefix if present (some MCP servers send
|
||||
# full data URIs instead of raw base64).
|
||||
if blob.startswith("data:"):
|
||||
blob = blob.split(",", 1)[-1]
|
||||
return base64.b64decode(blob)
|
||||
|
||||
text = "\n".join(c.text for c in self._result.contents if isinstance(c, TextResourceContents))
|
||||
return text if text else None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
|
||||
class MCPSkill(Skill):
|
||||
"""A :class:`Skill` discovered from an MCP server exposing the Agent Skills convention.
|
||||
|
||||
The skill is constructed from ``skill://index.json`` discovery metadata;
|
||||
:meth:`get_content` fetches the full ``SKILL.md`` content from the MCP
|
||||
server on demand via ``resources/read``.
|
||||
|
||||
Per SEP-2640, resources referenced inside SKILL.md are fetched on demand
|
||||
via the originating MCP server: :meth:`get_resource` resolves a relative
|
||||
resource name against the skill's root URI, issues a ``resources/read``
|
||||
request, and returns an :class:`MCPSkillResource` with pre-fetched content.
|
||||
"""
|
||||
|
||||
_SKILL_MD_SUFFIX: Final[str] = "SKILL.md"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
frontmatter: SkillFrontmatter,
|
||||
skill_md_uri: str,
|
||||
client: ClientSession,
|
||||
) -> None:
|
||||
"""Initialize an MCPSkill.
|
||||
|
||||
Args:
|
||||
frontmatter: The parsed frontmatter metadata for this skill.
|
||||
skill_md_uri: The full MCP resource URI of the ``SKILL.md`` resource
|
||||
(e.g. ``skill://unit-converter/SKILL.md``). The skill's root URI
|
||||
is derived by stripping the trailing ``SKILL.md`` segment.
|
||||
client: The MCP client session used to fetch resources on demand.
|
||||
"""
|
||||
self._frontmatter = frontmatter
|
||||
self._skill_md_uri = skill_md_uri
|
||||
self._skill_root_uri = self._compute_skill_root_uri(skill_md_uri)
|
||||
self._client = client
|
||||
self._content: str | None = None
|
||||
|
||||
@property
|
||||
def frontmatter(self) -> SkillFrontmatter:
|
||||
"""The L1 discovery metadata for this skill."""
|
||||
return self._frontmatter
|
||||
|
||||
async def get_content(self) -> str:
|
||||
"""Get the full SKILL.md content from the MCP server.
|
||||
|
||||
Fetches the content via ``resources/read`` on the first call and
|
||||
caches the result for subsequent calls.
|
||||
|
||||
Returns:
|
||||
The SKILL.md content string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the MCP server returned no text content for the
|
||||
SKILL.md resource.
|
||||
"""
|
||||
if self._content is not None:
|
||||
return self._content
|
||||
|
||||
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
|
||||
text = _mcp_join_text(result)
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
|
||||
)
|
||||
self._content = text
|
||||
return text
|
||||
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a sibling resource by name from the MCP server.
|
||||
|
||||
Resolves *name* as a relative path against the skill's root URI,
|
||||
issues a ``resources/read`` request to the MCP server, and returns
|
||||
an :class:`MCPSkillResource` with the pre-fetched content.
|
||||
|
||||
Args:
|
||||
name: The resource name (e.g. ``references/checklist.md``).
|
||||
|
||||
Returns:
|
||||
An :class:`MCPSkillResource`, or ``None`` when the name is empty
|
||||
or the resource does not exist on the server.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return None
|
||||
|
||||
normalized = self._validate_resource_name(name)
|
||||
if normalized is None:
|
||||
return None
|
||||
|
||||
uri = self._skill_root_uri + normalized
|
||||
try:
|
||||
result = await self._client.read_resource(_mcp_any_url(uri))
|
||||
except Exception as ex:
|
||||
if _is_mcp_resource_not_found(ex):
|
||||
logger.debug("MCP resource '%s' not available: %s", uri, ex)
|
||||
return None
|
||||
raise
|
||||
|
||||
return MCPSkillResource(name=name, result=result)
|
||||
|
||||
@staticmethod
|
||||
def _validate_resource_name(name: str) -> str | None:
|
||||
"""Validate a resource name and return the normalized form.
|
||||
|
||||
Defense in depth: refuses names that could escape the skill root
|
||||
(absolute paths, embedded URI schemes, parent-traversal segments).
|
||||
The MCP server is the authority on URI resolution, but rejecting
|
||||
obviously unsafe shapes client-side avoids leaking escape attempts
|
||||
upstream.
|
||||
|
||||
Args:
|
||||
name: The raw resource name to validate.
|
||||
|
||||
Returns:
|
||||
The normalized name with backslashes replaced by forward slashes,
|
||||
or ``None`` if the name is unsafe.
|
||||
"""
|
||||
normalized = name.replace("\\", "/")
|
||||
if (
|
||||
normalized.startswith("/")
|
||||
or "://" in normalized
|
||||
or any(seg == ".." for seg in normalized.split("/"))
|
||||
):
|
||||
logger.debug("Rejecting resource name with unsafe path components: %r", name)
|
||||
return None
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _compute_skill_root_uri(skill_md_uri: str) -> str:
|
||||
"""Strip the trailing ``SKILL.md`` from the URI to produce the skill root.
|
||||
|
||||
If the URI doesn't end with ``SKILL.md``, ensures it ends with a
|
||||
trailing slash.
|
||||
"""
|
||||
if skill_md_uri.endswith(MCPSkill._SKILL_MD_SUFFIX):
|
||||
return skill_md_uri[: -len(MCPSkill._SKILL_MD_SUFFIX)]
|
||||
if skill_md_uri.endswith("/"):
|
||||
return skill_md_uri
|
||||
return skill_md_uri + "/"
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
|
||||
class MCPSkillsSource(SkillsSource):
|
||||
"""A :class:`SkillsSource` that discovers Agent Skills served over MCP.
|
||||
|
||||
Discovery follows the SEP-2640 recommended approach: the source reads
|
||||
the well-known ``skill://index.json`` resource and constructs one
|
||||
:class:`MCPSkill` per ``skill-md`` entry directly from the entry's
|
||||
``name``, ``description``, and ``url`` fields.
|
||||
|
||||
The referenced ``SKILL.md`` resource is **not** read during discovery;
|
||||
the host fetches its body on demand via ``resources/read`` when the
|
||||
skill content is needed.
|
||||
|
||||
Only index entries of type ``skill-md`` are supported; entries of any
|
||||
other type are silently skipped.
|
||||
|
||||
If ``skill://index.json`` is absent, unreadable, empty, or fails to
|
||||
parse, this source returns an empty list.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
source = MCPSkillsSource(client=session)
|
||||
skills = await source.get_skills()
|
||||
"""
|
||||
|
||||
_INDEX_URI: Final[str] = "skill://index.json"
|
||||
_SKILL_MD_TYPE: Final[str] = "skill-md"
|
||||
|
||||
def __init__(self, client: ClientSession) -> None:
|
||||
"""Initialize an MCPSkillsSource.
|
||||
|
||||
Args:
|
||||
client: An MCP client session connected to a server that
|
||||
exposes Agent Skills resources.
|
||||
"""
|
||||
self._client = client
|
||||
|
||||
async def get_skills(self) -> list[Skill]:
|
||||
"""Discover and return skills from the MCP server.
|
||||
|
||||
Reads ``skill://index.json``, parses it, and creates an
|
||||
:class:`MCPSkill` for each valid ``skill-md`` entry.
|
||||
|
||||
Returns:
|
||||
A list of discovered :class:`MCPSkill` instances.
|
||||
"""
|
||||
index = await self._try_read_index()
|
||||
if index is None:
|
||||
return []
|
||||
|
||||
skills: list[Skill] = []
|
||||
for entry in index.skills:
|
||||
result = self._try_create_skill(entry)
|
||||
if result is not None:
|
||||
skills.append(result)
|
||||
logger.info("Loaded MCP skill: %s", result.frontmatter.name)
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping skill index entry '%s'",
|
||||
entry.name or "(unnamed)",
|
||||
)
|
||||
|
||||
logger.info("Successfully loaded %d skills from MCP server", len(skills))
|
||||
return skills
|
||||
|
||||
async def _try_read_index(self) -> _McpSkillIndex | None:
|
||||
"""Attempt to read and parse ``skill://index.json`` from the MCP server.
|
||||
|
||||
Returns:
|
||||
A parsed :class:`_McpSkillIndex`, or ``None`` if the index is
|
||||
absent, empty, or malformed.
|
||||
"""
|
||||
try:
|
||||
result = await self._client.read_resource(_mcp_any_url(self._INDEX_URI))
|
||||
except Exception as ex:
|
||||
if _is_mcp_resource_not_found(ex):
|
||||
logger.debug("No skill://index.json resource available on MCP server: %s", ex)
|
||||
return None
|
||||
logger.warning("Failed to read skill://index.json from MCP server.", exc_info=True)
|
||||
raise
|
||||
|
||||
index_text = _mcp_join_text(result)
|
||||
if not index_text:
|
||||
logger.debug("skill://index.json on MCP server returned empty/non-text contents")
|
||||
return None
|
||||
|
||||
try:
|
||||
return _parse_mcp_skill_index(index_text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Failed to parse skill://index.json JSON document.", exc_info=True)
|
||||
return None
|
||||
|
||||
def _try_create_skill(self, entry: _McpSkillIndexEntry) -> MCPSkill | None:
|
||||
"""Attempt to create an :class:`MCPSkill` from an index entry.
|
||||
|
||||
Args:
|
||||
entry: A single entry from the skill index.
|
||||
|
||||
Returns:
|
||||
An :class:`MCPSkill` if the entry is valid, or ``None`` if the
|
||||
entry should be skipped.
|
||||
"""
|
||||
if entry.type != self._SKILL_MD_TYPE:
|
||||
logger.debug(
|
||||
"Skipping entry '%s': unsupported type '%s'",
|
||||
entry.name or "(unnamed)",
|
||||
entry.type or "(none)",
|
||||
)
|
||||
return None
|
||||
|
||||
if not entry.name or not entry.name.strip():
|
||||
logger.debug("Skipping entry: missing required 'name' field")
|
||||
return None
|
||||
|
||||
if not entry.description or not entry.description.strip():
|
||||
logger.debug("Skipping entry '%s': missing required 'description' field", entry.name)
|
||||
return None
|
||||
|
||||
if not entry.url or not entry.url.strip():
|
||||
logger.debug("Skipping entry '%s': missing required 'url' field", entry.name)
|
||||
return None
|
||||
|
||||
try:
|
||||
fm = SkillFrontmatter(name=entry.name, description=entry.description)
|
||||
except ValueError as ex:
|
||||
logger.debug("Skipping entry '%s': invalid metadata: %s", entry.name, ex)
|
||||
return None
|
||||
|
||||
return MCPSkill(frontmatter=fm, skill_md_uri=entry.url, client=self._client)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -292,6 +292,7 @@ class FunctionTool(SerializationMixin):
|
||||
"_cached_parameters",
|
||||
"_input_schema",
|
||||
"_schema_supplied",
|
||||
"_invoke_sync_on_event_loop",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -366,6 +367,7 @@ class FunctionTool(SerializationMixin):
|
||||
self.description = description
|
||||
self.kind = kind
|
||||
self.additional_properties = additional_properties
|
||||
self._invoke_sync_on_event_loop = False
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
@@ -537,6 +539,16 @@ class FunctionTool(SerializationMixin):
|
||||
self.invocation_exception_count += 1
|
||||
raise
|
||||
|
||||
async def _invoke_function(self, call_kwargs: Mapping[str, Any]) -> Any:
|
||||
"""Run sync tools off the event loop during async invocation."""
|
||||
func = self.func.func if isinstance(self.func, FunctionTool) else self.func
|
||||
if inspect.iscoroutinefunction(func) or getattr(self, "_invoke_sync_on_event_loop", False):
|
||||
res = self.__call__(**call_kwargs)
|
||||
return await res if inspect.isawaitable(res) else res
|
||||
|
||||
res = await asyncio.to_thread(self.__call__, **call_kwargs)
|
||||
return await res if inspect.isawaitable(res) else res
|
||||
|
||||
@overload
|
||||
async def invoke(
|
||||
self,
|
||||
@@ -679,8 +691,7 @@ class FunctionTool(SerializationMixin):
|
||||
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
|
||||
logger.info(f"Function name: {self.name}")
|
||||
logger.debug(f"Function arguments: {observable_kwargs}")
|
||||
res = self.__call__(**call_kwargs)
|
||||
result = await res if inspect.isawaitable(res) else res
|
||||
result = await self._invoke_function(call_kwargs)
|
||||
if skip_parsing:
|
||||
logger.info(f"Function {self.name} succeeded.")
|
||||
logger.debug(f"Function result: {type(result).__name__}")
|
||||
@@ -730,8 +741,7 @@ class FunctionTool(SerializationMixin):
|
||||
start_time_stamp = perf_counter()
|
||||
end_time_stamp: float | None = None
|
||||
try:
|
||||
res = self.__call__(**call_kwargs)
|
||||
result = await res if inspect.isawaitable(res) else res
|
||||
result = await self._invoke_function(call_kwargs)
|
||||
end_time_stamp = perf_counter()
|
||||
except Exception as exception:
|
||||
end_time_stamp = perf_counter()
|
||||
@@ -1418,6 +1428,7 @@ async def _auto_invoke_function(
|
||||
sequence_index: int | None = None,
|
||||
request_index: int | None = None,
|
||||
middleware_pipeline: FunctionMiddlewarePipeline | None = None,
|
||||
live_tools: list[ToolTypes] | None = None,
|
||||
) -> Content:
|
||||
"""Invoke a function call requested by the agent, applying middleware that is defined.
|
||||
|
||||
@@ -1432,6 +1443,8 @@ async def _auto_invoke_function(
|
||||
sequence_index: The index of the function call in the sequence.
|
||||
request_index: The index of the request iteration.
|
||||
middleware_pipeline: Optional middleware pipeline to apply during execution.
|
||||
live_tools: The live, mutable tools list for the current agent run, exposed on
|
||||
the FunctionInvocationContext so tools can add/remove tools at runtime.
|
||||
|
||||
Returns:
|
||||
The function result content.
|
||||
@@ -1523,6 +1536,7 @@ async def _auto_invoke_function(
|
||||
arguments=args,
|
||||
session=invocation_session,
|
||||
kwargs=runtime_kwargs.copy(),
|
||||
tools=live_tools,
|
||||
)
|
||||
function_result = await tool.invoke(
|
||||
arguments=args,
|
||||
@@ -1537,6 +1551,10 @@ async def _auto_invoke_function(
|
||||
except UserInputRequiredException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Function '{tool.name}' raised an exception; returning an error result to the "
|
||||
f"model. Set include_detailed_errors=True for the full detail. Exception: {exc!r}"
|
||||
)
|
||||
message = "Error: Function failed."
|
||||
if config.get("include_detailed_errors", False):
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1552,6 +1570,7 @@ async def _auto_invoke_function(
|
||||
arguments=args,
|
||||
session=invocation_session,
|
||||
kwargs=runtime_kwargs.copy(),
|
||||
tools=live_tools,
|
||||
)
|
||||
|
||||
call_id = function_call_content.call_id
|
||||
@@ -1608,6 +1627,10 @@ async def _auto_invoke_function(
|
||||
except UserInputRequiredException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Function '{tool.name}' raised an exception; returning an error result to the "
|
||||
f"model. Set include_detailed_errors=True for the full detail. Exception: {exc!r}"
|
||||
)
|
||||
message = "Error: Function failed."
|
||||
if config.get("include_detailed_errors", False):
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1659,6 +1682,9 @@ async def _try_execute_function_calls(
|
||||
from ._types import Content
|
||||
|
||||
tool_map = _get_tool_map(tools)
|
||||
# The live tools list (when tools is the run-local list) is exposed on the
|
||||
# FunctionInvocationContext so tools can add/remove tools during the run.
|
||||
live_tools: list[ToolTypes] | None = cast("list[ToolTypes]", tools) if isinstance(tools, list) else None
|
||||
approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"]
|
||||
logger.debug(
|
||||
"_try_execute_function_calls: tool_map keys=%s, approval_tools=%s",
|
||||
@@ -1733,6 +1759,7 @@ async def _try_execute_function_calls(
|
||||
request_index=attempt_idx,
|
||||
middleware_pipeline=middleware_pipeline,
|
||||
config=config,
|
||||
live_tools=live_tools,
|
||||
)
|
||||
return (result, False)
|
||||
except MiddlewareTermination as exc:
|
||||
@@ -2371,6 +2398,13 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=filtered_kwargs,
|
||||
)
|
||||
# Establish a single, run-local mutable tools list so that tools can add or remove
|
||||
# tools during the run (progressive tool exposure). A fresh list is created via
|
||||
# normalize_tools so the caller's original tools container is never mutated, while
|
||||
# the same list object is shared with the model (options["tools"]) and the tool map
|
||||
# rebuilt on every loop iteration.
|
||||
if mutable_options.get("tools"):
|
||||
mutable_options["tools"] = normalize_tools(mutable_options["tools"])
|
||||
if not stream:
|
||||
|
||||
async def _get_response() -> ChatResponse[Any]:
|
||||
|
||||
@@ -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.7.0"
|
||||
version = "1.8.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -11,10 +11,14 @@ from agent_framework import (
|
||||
GROUP_TOKEN_COUNT_KEY,
|
||||
BaseChatClient,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
SlidingWindowStrategy,
|
||||
SupportsChatGetResponse,
|
||||
ToolResultCompactionStrategy,
|
||||
TruncationStrategy,
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
@@ -258,6 +262,196 @@ async def test_base_client_default_tokenizer_without_strategy_annotates_messages
|
||||
assert captured_token_counts == [[19, 19]]
|
||||
|
||||
|
||||
def _tool_call_response(call_id: str, location: str) -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id=call_id,
|
||||
name="lookup_weather",
|
||||
arguments=f'{{"location": "{location}"}}',
|
||||
)
|
||||
],
|
||||
),
|
||||
response_id=f"resp_{call_id}",
|
||||
)
|
||||
|
||||
|
||||
def _is_tool_result_summary(message: Message) -> bool:
|
||||
text = message.text or ""
|
||||
return message.role == "assistant" and text.startswith("[Tool results:")
|
||||
|
||||
|
||||
async def test_function_loop_persists_inserted_summaries_across_iterations(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
# Regression test for #4991: compaction inserts summary messages and excludes the
|
||||
# originals. Across tool-loop iterations the exclusion flags persisted (shared Message
|
||||
# objects) but the inserted summaries were dropped (they only lived on a throwaway copy),
|
||||
# so older tool groups were silently lost with no summary representing them.
|
||||
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
chat_client_base.run_responses = [ # type: ignore[attr-defined]
|
||||
_tool_call_response("call_1", "London"),
|
||||
_tool_call_response("call_2", "Paris"),
|
||||
_tool_call_response("call_3", "Tokyo"),
|
||||
]
|
||||
|
||||
captured_inputs: list[list[Message]] = []
|
||||
original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
|
||||
|
||||
async def _capture(
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
captured_inputs.append(list(messages))
|
||||
return await original(messages=messages, options=options, **kwargs)
|
||||
|
||||
chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
|
||||
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["What is the weather in London?"])],
|
||||
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
|
||||
)
|
||||
|
||||
# The final model call should represent every compacted tool group with a summary.
|
||||
# Two older tool groups get collapsed (London, Paris) while the last (Tokyo) is kept.
|
||||
final_input = captured_inputs[-1]
|
||||
summaries = [message for message in final_input if _is_tool_result_summary(message)]
|
||||
summary_text = " ".join(message.text or "" for message in summaries)
|
||||
|
||||
assert len(summaries) == 2, [message.text for message in final_input]
|
||||
assert "London" in summary_text
|
||||
assert "Paris" in summary_text
|
||||
|
||||
|
||||
def _tool_call_update(call_id: str, location: str) -> list[ChatResponseUpdate]:
|
||||
return [
|
||||
ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id=call_id,
|
||||
name="lookup_weather",
|
||||
arguments=f'{{"location": "{location}"}}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
response_id=f"resp_{call_id}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_function_loop_persists_inserted_summaries_across_iterations_streaming(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
# Streaming counterpart of the #4991 regression test: the summary persistence fix in
|
||||
# ``_prepare_messages_for_model_call`` must cover the streaming tool loop too.
|
||||
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
chat_client_base.streaming_responses = [ # type: ignore[attr-defined]
|
||||
_tool_call_update("call_1", "London"),
|
||||
_tool_call_update("call_2", "Paris"),
|
||||
_tool_call_update("call_3", "Tokyo"),
|
||||
]
|
||||
|
||||
captured_inputs: list[list[Message]] = []
|
||||
original = chat_client_base._get_streaming_response # type: ignore[attr-defined]
|
||||
|
||||
def _capture(
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
):
|
||||
captured_inputs.append(list(messages))
|
||||
return original(messages=messages, options=options, **kwargs)
|
||||
|
||||
chat_client_base._get_streaming_response = _capture # type: ignore[attr-defined,method-assign]
|
||||
|
||||
stream = chat_client_base.get_response(
|
||||
[Message(role="user", contents=["What is the weather in London?"])],
|
||||
stream=True,
|
||||
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
|
||||
)
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
final_input = captured_inputs[-1]
|
||||
summaries = [message for message in final_input if _is_tool_result_summary(message)]
|
||||
summary_text = " ".join(message.text or "" for message in summaries)
|
||||
|
||||
assert len(summaries) == 2, [message.text for message in final_input]
|
||||
assert "London" in summary_text
|
||||
assert "Paris" in summary_text
|
||||
|
||||
|
||||
async def test_function_loop_compaction_conversation_id_mode_does_not_resend_history(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
# In conversation-id mode the server owns prior context, so the tool loop clears
|
||||
# ``prepped_messages`` and only sends the latest message. Compaction must not fight that
|
||||
# by re-inserting summaries or re-sending earlier turns.
|
||||
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
def _conversation_tool_call(call_id: str, location: str) -> ChatResponse:
|
||||
response = _tool_call_response(call_id, location)
|
||||
response.conversation_id = "conv_1"
|
||||
return response
|
||||
|
||||
chat_client_base.run_responses = [ # type: ignore[attr-defined]
|
||||
_conversation_tool_call("call_1", "London"),
|
||||
_conversation_tool_call("call_2", "Paris"),
|
||||
_conversation_tool_call("call_3", "Tokyo"),
|
||||
]
|
||||
|
||||
captured_inputs: list[list[Message]] = []
|
||||
original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
|
||||
|
||||
async def _capture(
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
captured_inputs.append(list(messages))
|
||||
return await original(messages=messages, options=options, **kwargs)
|
||||
|
||||
chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
|
||||
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["What is the weather in London?"])],
|
||||
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
|
||||
)
|
||||
|
||||
# After the conversation id is established the loop only forwards the latest message,
|
||||
# so subsequent model calls never receive the full history or summary messages.
|
||||
for sent in captured_inputs[1:]:
|
||||
assert len(sent) <= 1, [message.text for message in sent]
|
||||
assert not any(_is_tool_result_summary(message) for message in sent)
|
||||
|
||||
|
||||
def test_base_client_as_agent_does_not_copy_client_compaction_defaults(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
|
||||
@@ -196,6 +196,64 @@ def test_append_compaction_message_annotates_new_message() -> None:
|
||||
assert isinstance(_group_id(messages[1]), str)
|
||||
|
||||
|
||||
def test_incremental_annotation_assigns_unique_message_ids() -> None:
|
||||
# Regression test for #5237: ``_ensure_message_ids`` assigned ``msg_{index}``
|
||||
# using the position within the slice handed to ``group_messages``. Successive
|
||||
# incremental annotations restart the index at 0, so distinct messages collided
|
||||
# on the same ``message_id``.
|
||||
messages: list[Message] = []
|
||||
for turn in range(4):
|
||||
messages.append(Message(role="user", contents=[f"user {turn}"]))
|
||||
annotate_message_groups(messages)
|
||||
messages.append(Message(role="assistant", contents=[f"assistant {turn}"]))
|
||||
annotate_message_groups(messages)
|
||||
|
||||
message_ids = [message.message_id for message in messages]
|
||||
assert all(message_ids), "every message should receive an id"
|
||||
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
|
||||
|
||||
|
||||
def test_ensure_message_ids_avoids_existing_id_collisions() -> None:
|
||||
# An auto-generated ``msg_{index}`` must not collide with an id already present
|
||||
# on another message (user-supplied or assigned by an earlier annotation pass).
|
||||
messages = [
|
||||
Message(role="user", contents=["zero"]),
|
||||
Message(role="assistant", contents=["one"], message_id="msg_2"),
|
||||
Message(role="user", contents=["two"]),
|
||||
]
|
||||
annotate_message_groups(messages)
|
||||
|
||||
message_ids = [message.message_id for message in messages]
|
||||
assert message_ids[1] == "msg_2"
|
||||
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
|
||||
|
||||
|
||||
def test_incremental_annotation_avoids_prefix_id_collision() -> None:
|
||||
# Regression for the PR review on #5237: when only a suffix is re-annotated,
|
||||
# an auto-assigned ``msg_{index}`` in the suffix must not collide with a
|
||||
# preexisting id carried by a message in the *preserved prefix* (a group
|
||||
# before the one re-annotation pulls back to). Otherwise ``_group_id_for``
|
||||
# derives the same group id and merges groups across the boundary.
|
||||
messages = [
|
||||
# Out-of-position, user-supplied id that matches the ``msg_{index}`` the
|
||||
# suffix pass would assign to the appended message below. This message is
|
||||
# two groups back, so it stays outside the re-annotated slice.
|
||||
Message(role="user", contents=["zero"], message_id="msg_2"),
|
||||
Message(role="user", contents=["one"]),
|
||||
]
|
||||
annotate_message_groups(messages)
|
||||
assert messages[0].message_id == "msg_2"
|
||||
assert messages[1].message_id == "msg_1"
|
||||
|
||||
messages.append(Message(role="user", contents=["two"]))
|
||||
annotate_message_groups(messages, from_index=2)
|
||||
|
||||
message_ids = [message.message_id for message in messages]
|
||||
assert all(message_ids), "every message should receive an id"
|
||||
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
|
||||
assert messages[0].message_id == "msg_2"
|
||||
|
||||
|
||||
async def test_truncation_strategy_keeps_system_anchor() -> None:
|
||||
messages = [
|
||||
Message(role="system", contents=["you are helpful"]),
|
||||
@@ -484,6 +542,44 @@ async def test_tool_result_compaction_collapses_old_groups_into_summary() -> Non
|
||||
assert any(m.role == "tool" for m in projected)
|
||||
|
||||
|
||||
async def test_tool_result_compaction_is_idempotent_after_summary_insertion() -> None:
|
||||
"""Re-running compaction after a mid-list summary insertion must not duplicate it.
|
||||
|
||||
Mirrors a subsequent tool-loop iteration (issue #4991): the inserted summary and the
|
||||
excluded originals now persist on the same list, so a second annotate + compaction pass
|
||||
over the same groups should be a no-op rather than collapsing the group again.
|
||||
"""
|
||||
messages = [
|
||||
Message(role="user", contents=["u"]),
|
||||
_assistant_function_call("call-1"),
|
||||
_tool_result("call-1", "r1"),
|
||||
_assistant_function_call("call-2"),
|
||||
_tool_result("call-2", "r2"),
|
||||
Message(role="assistant", contents=["done"]),
|
||||
]
|
||||
strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1)
|
||||
annotate_message_groups(messages)
|
||||
assert await strategy(messages) is True
|
||||
|
||||
summaries_after_first = [m for m in messages if (m.text or "").startswith("[Tool results:")]
|
||||
assert len(summaries_after_first) == 1
|
||||
summary = summaries_after_first[0]
|
||||
summary_group_ids = _group_unknown_value(summary, SUMMARY_OF_GROUP_IDS_KEY)
|
||||
|
||||
# Second pass over the same (now partially compacted) list.
|
||||
annotate_message_groups(messages)
|
||||
changed = await strategy(messages)
|
||||
|
||||
assert changed is False
|
||||
summaries_after_second = [m for m in messages if (m.text or "").startswith("[Tool results:")]
|
||||
assert len(summaries_after_second) == 1
|
||||
assert _group_unknown_value(summaries_after_second[0], SUMMARY_OF_GROUP_IDS_KEY) == summary_group_ids
|
||||
|
||||
# The kept tool-call group stays atomic and included.
|
||||
projected = included_messages(messages)
|
||||
assert any(m.role == "tool" for m in projected)
|
||||
|
||||
|
||||
async def test_tool_result_compaction_zero_collapses_all() -> None:
|
||||
"""With keep=0, all tool-call groups are collapsed into summaries."""
|
||||
messages = [
|
||||
|
||||
@@ -3975,3 +3975,425 @@ async def test_user_input_request_empty_contents_returns_fallback(chat_client_ba
|
||||
]
|
||||
assert len(function_results) >= 1
|
||||
assert any("user input" in (fr.result or "").lower() for fr in function_results)
|
||||
|
||||
|
||||
# region Progressive tool exposure (FunctionInvocationContext.add_tools / remove_tools)
|
||||
|
||||
|
||||
def _pte_function_call_response(call_id: str, name: str, arguments: str = "{}") -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id=call_id, name=name, arguments=arguments)],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _pte_text_response(text: str = "done") -> ChatResponse:
|
||||
return ChatResponse(messages=Message(role="assistant", contents=[text]))
|
||||
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def _pte_factorial(n: int) -> int:
|
||||
"""Compute the factorial of n."""
|
||||
result = 1
|
||||
for value in range(2, n + 1):
|
||||
result *= value
|
||||
return result
|
||||
|
||||
|
||||
async def test_context_exposes_live_tools(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
seen_names: list[str] = []
|
||||
|
||||
@tool(name="inspect_tools", approval_mode="never_require")
|
||||
def inspect_tools(ctx: FunctionInvocationContext) -> str:
|
||||
assert ctx.tools is not None
|
||||
seen_names.extend(t.name for t in ctx.tools if isinstance(t, FunctionTool))
|
||||
return "inspected"
|
||||
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "inspect_tools"),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [inspect_tools]},
|
||||
)
|
||||
assert "inspect_tools" in seen_names
|
||||
|
||||
|
||||
async def test_add_tools_available_next_iteration(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def factorial(n: int) -> int:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(factorial)
|
||||
return "math tools loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
response = await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["compute 5!"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
assert exec_counter == 1
|
||||
assert response.messages[-1].text == "done"
|
||||
|
||||
|
||||
async def test_add_tools_model_sees_added_tools_in_options(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
recorded: list[list[str]] = []
|
||||
client_cls = type(chat_client_base)
|
||||
original = client_cls._get_non_streaming_response
|
||||
|
||||
async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse:
|
||||
tools = options.get("tools") or []
|
||||
recorded.append([t.name for t in tools if isinstance(t, FunctionTool)])
|
||||
return await original(self, messages=messages, options=options, **kwargs)
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(_pte_factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(client_cls, "_get_non_streaming_response", recording)
|
||||
try:
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["compute 5!"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert recorded[0] == ["load_math"]
|
||||
assert "factorial" in recorded[1]
|
||||
|
||||
|
||||
async def test_remove_tools_next_iteration(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
recorded: list[list[str]] = []
|
||||
client_cls = type(chat_client_base)
|
||||
original = client_cls._get_non_streaming_response
|
||||
|
||||
async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse:
|
||||
tools = options.get("tools") or []
|
||||
recorded.append([t.name for t in tools if isinstance(t, FunctionTool)])
|
||||
return await original(self, messages=messages, options=options, **kwargs)
|
||||
|
||||
@tool(name="get_weather", approval_mode="never_require")
|
||||
def get_weather(location: str) -> str:
|
||||
return "sunny"
|
||||
|
||||
@tool(name="drop_weather", approval_mode="never_require")
|
||||
def drop_weather(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.remove_tools("get_weather")
|
||||
return "removed"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "drop_weather"),
|
||||
_pte_text_response(),
|
||||
]
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(client_cls, "_get_non_streaming_response", recording)
|
||||
try:
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [get_weather, drop_weather]},
|
||||
)
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert set(recorded[0]) == {"get_weather", "drop_weather"}
|
||||
assert "get_weather" not in recorded[1]
|
||||
|
||||
|
||||
async def test_add_tools_does_not_mutate_caller_tools_list(chat_client_base: SupportsChatGetResponse):
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(_pte_factorial)
|
||||
return "loaded"
|
||||
|
||||
original_tools: list[Any] = [load_math]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": original_tools},
|
||||
)
|
||||
assert original_tools == [load_math]
|
||||
|
||||
|
||||
async def test_add_tools_persists_across_iterations(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
recorded: list[list[str]] = []
|
||||
client_cls = type(chat_client_base)
|
||||
original = client_cls._get_non_streaming_response
|
||||
|
||||
async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse:
|
||||
tools = options.get("tools") or []
|
||||
recorded.append([t.name for t in tools if isinstance(t, FunctionTool)])
|
||||
return await original(self, messages=messages, options=options, **kwargs)
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(_pte_factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 4 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_function_call_response("3", "factorial", '{"n": 3}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(client_cls, "_get_non_streaming_response", recording)
|
||||
try:
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert "factorial" in recorded[1]
|
||||
assert "factorial" in recorded[2]
|
||||
|
||||
|
||||
async def test_add_tools_through_function_middleware(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
class PassthroughMiddleware(FunctionMiddleware):
|
||||
async def process(self, context: FunctionInvocationContext, call_next: Any) -> None:
|
||||
await call_next()
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def factorial(n: int) -> int:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
middleware=[PassthroughMiddleware()],
|
||||
)
|
||||
assert exec_counter == 1
|
||||
|
||||
|
||||
async def test_add_tools_with_approval_required_tool(chat_client_base: SupportsChatGetResponse):
|
||||
@tool(name="secure_tool", approval_mode="always_require")
|
||||
def secure_tool(value: str) -> str:
|
||||
return f"secure: {value}"
|
||||
|
||||
@tool(name="load_secure", approval_mode="never_require")
|
||||
def load_secure(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(secure_tool)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_secure"),
|
||||
_pte_function_call_response("2", "secure_tool", '{"value": "x"}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
response = await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_secure]},
|
||||
)
|
||||
assert any(item.type == "function_approval_request" for msg in response.messages for item in msg.contents)
|
||||
|
||||
|
||||
async def test_add_tools_accepts_plain_callable(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
def plain_factorial(n: int) -> int:
|
||||
"""Compute factorial."""
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(plain_factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "plain_factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
assert exec_counter == 1
|
||||
|
||||
|
||||
async def test_add_tools_streaming(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def factorial(n: int) -> int:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.streaming_responses = [
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[Content.from_function_call(call_id="1", name="load_math", arguments="{}")],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[Content.from_function_call(call_id="2", name="factorial", arguments='{"n": 5}')],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
[ChatResponseUpdate(contents=[Content.from_text("done")], role="assistant", finish_reason="stop")],
|
||||
]
|
||||
async for _ in chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
stream=True,
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
):
|
||||
pass
|
||||
assert exec_counter == 1
|
||||
|
||||
|
||||
def test_add_tools_duplicate_same_object_is_noop():
|
||||
@tool(name="dup", approval_mode="never_require")
|
||||
def dup(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=dup, arguments={}, tools=[dup])
|
||||
ctx.add_tools(dup)
|
||||
assert ctx.tools is not None
|
||||
assert len(ctx.tools) == 1
|
||||
|
||||
|
||||
def test_add_tools_duplicate_name_different_object_raises():
|
||||
@tool(name="dup", approval_mode="never_require")
|
||||
def dup_a(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="dup", approval_mode="never_require")
|
||||
def dup_b(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=dup_a, arguments={}, tools=[dup_a])
|
||||
with pytest.raises(ValueError):
|
||||
ctx.add_tools(dup_b)
|
||||
|
||||
|
||||
def test_add_tools_batch_with_duplicate_is_atomic():
|
||||
"""A duplicate-name clash partway through a batch must leave the live list unchanged."""
|
||||
|
||||
@tool(name="existing", approval_mode="never_require")
|
||||
def existing(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="fresh", approval_mode="never_require")
|
||||
def fresh(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="existing", approval_mode="never_require")
|
||||
def clashing(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=existing, arguments={}, tools=[existing])
|
||||
with pytest.raises(ValueError):
|
||||
ctx.add_tools([fresh, clashing])
|
||||
assert ctx.tools is not None
|
||||
# The valid "fresh" tool must not have been committed before the clash raised.
|
||||
assert ctx.tools == [existing]
|
||||
|
||||
|
||||
def test_remove_tools_by_name_and_object():
|
||||
@tool(name="a", approval_mode="never_require")
|
||||
def a(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="b", approval_mode="never_require")
|
||||
def b(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=a, arguments={}, tools=[a, b])
|
||||
ctx.remove_tools("a")
|
||||
assert ctx.tools is not None
|
||||
assert [t.name for t in ctx.tools] == ["b"]
|
||||
ctx.remove_tools(b)
|
||||
assert ctx.tools == []
|
||||
|
||||
|
||||
def test_remove_tools_unknown_name_is_noop():
|
||||
@tool(name="a", approval_mode="never_require")
|
||||
def a(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=a, arguments={}, tools=[a])
|
||||
ctx.remove_tools("nonexistent")
|
||||
assert ctx.tools is not None
|
||||
assert [t.name for t in ctx.tools] == ["a"]
|
||||
|
||||
|
||||
def test_progressive_tools_helpers_raise_without_live_tools():
|
||||
@tool(name="a", approval_mode="never_require")
|
||||
def a(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=a, arguments={})
|
||||
assert ctx.tools is None
|
||||
with pytest.raises(RuntimeError):
|
||||
ctx.add_tools(a)
|
||||
with pytest.raises(RuntimeError):
|
||||
ctx.remove_tools("a")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for MCP-based skills (MCPSkillsSource, MCPSkill, MCPSkillResource)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
ErrorData,
|
||||
ReadResourceResult,
|
||||
TextResourceContents,
|
||||
)
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from agent_framework import MCPSkill, MCPSkillResource, MCPSkillsSource
|
||||
from agent_framework._skills import _parse_mcp_skill_index
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures & helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_SKILL_MD = """\
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units.
|
||||
---
|
||||
# Unit Converter
|
||||
|
||||
Body content here.
|
||||
"""
|
||||
|
||||
SAMPLE_SKILL_INDEX = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with a single TextResourceContents."""
|
||||
return ReadResourceResult(
|
||||
contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")]
|
||||
)
|
||||
|
||||
|
||||
def _make_blob_result(
|
||||
data: bytes,
|
||||
uri: str = "skill://test",
|
||||
mime_type: str = "application/octet-stream",
|
||||
) -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with a single BlobResourceContents."""
|
||||
return ReadResourceResult(
|
||||
contents=[BlobResourceContents(uri=AnyUrl(uri), blob=base64.b64encode(data).decode(), mimeType=mime_type)]
|
||||
)
|
||||
|
||||
|
||||
def _make_empty_result() -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with no contents."""
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
|
||||
def _make_client(**read_resource_responses: ReadResourceResult) -> AsyncMock:
|
||||
"""Create a mock ClientSession whose read_resource returns different results per URI.
|
||||
|
||||
Args:
|
||||
**read_resource_responses: Mapping of URI string to ReadResourceResult.
|
||||
Any URI not in this mapping raises McpError with the MCP-spec
|
||||
"Resource not found" code (-32002).
|
||||
"""
|
||||
client = AsyncMock()
|
||||
|
||||
async def _read_resource(uri: AnyUrl) -> ReadResourceResult:
|
||||
uri_str = str(uri)
|
||||
if uri_str in read_resource_responses:
|
||||
return read_resource_responses[uri_str]
|
||||
raise McpError(error=ErrorData(code=-32002, message=f"Resource not found: {uri_str}"))
|
||||
|
||||
client.read_resource = AsyncMock(side_effect=_read_resource)
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_mcp_skill_index tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseMCPSkillIndex:
|
||||
"""Tests for the _parse_mcp_skill_index helper."""
|
||||
|
||||
def test_parses_valid_index(self) -> None:
|
||||
index = _parse_mcp_skill_index(SAMPLE_SKILL_INDEX)
|
||||
assert index.schema == "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
|
||||
assert len(index.skills) == 1
|
||||
assert index.skills[0].name == "unit-converter"
|
||||
assert index.skills[0].type == "skill-md"
|
||||
assert index.skills[0].url == "skill://unit-converter/SKILL.md"
|
||||
|
||||
def test_parses_empty_skills_array(self) -> None:
|
||||
index = _parse_mcp_skill_index('{"$schema": "test", "skills": []}')
|
||||
assert index.skills == []
|
||||
|
||||
def test_parses_missing_skills_key(self) -> None:
|
||||
index = _parse_mcp_skill_index('{"$schema": "test"}')
|
||||
assert index.skills == []
|
||||
|
||||
def test_raises_on_non_object(self) -> None:
|
||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
||||
_parse_mcp_skill_index("[]")
|
||||
|
||||
def test_raises_on_invalid_json(self) -> None:
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_parse_mcp_skill_index("not json")
|
||||
|
||||
def test_skips_non_dict_entries(self) -> None:
|
||||
index = _parse_mcp_skill_index('{"skills": ["not-a-dict", {"name": "ok", "type": "skill-md"}]}')
|
||||
assert len(index.skills) == 1
|
||||
assert index.skills[0].name == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSkillResource tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkillResource:
|
||||
"""Tests for MCPSkillResource."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_text_content(self) -> None:
|
||||
result = _make_text_result("hello world")
|
||||
resource = MCPSkillResource(name="test.md", result=result)
|
||||
content = await resource.read()
|
||||
assert content == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_binary_content(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
result = _make_blob_result(data)
|
||||
resource = MCPSkillResource(name="icon.bin", result=result)
|
||||
content = await resource.read()
|
||||
assert content == data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_empty_returns_none(self) -> None:
|
||||
result = _make_empty_result()
|
||||
resource = MCPSkillResource(name="empty", result=result)
|
||||
content = await resource.read()
|
||||
assert content is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_multiple_text_contents_joined(self) -> None:
|
||||
result = ReadResourceResult(
|
||||
contents=[
|
||||
TextResourceContents(uri=AnyUrl("skill://a"), text="line1", mimeType="text/plain"),
|
||||
TextResourceContents(uri=AnyUrl("skill://b"), text="line2", mimeType="text/plain"),
|
||||
]
|
||||
)
|
||||
resource = MCPSkillResource(name="multi", result=result)
|
||||
content = await resource.read()
|
||||
assert content == "line1\nline2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_takes_precedence_over_text(self) -> None:
|
||||
data = b"\xff\xfe"
|
||||
result = ReadResourceResult(
|
||||
contents=[
|
||||
TextResourceContents(uri=AnyUrl("skill://a"), text="text", mimeType="text/plain"),
|
||||
BlobResourceContents(
|
||||
uri=AnyUrl("skill://b"),
|
||||
blob=base64.b64encode(data).decode(),
|
||||
mimeType="application/octet-stream",
|
||||
),
|
||||
]
|
||||
)
|
||||
resource = MCPSkillResource(name="mixed", result=result)
|
||||
content = await resource.read()
|
||||
# The implementation iterates all contents checking for BlobResourceContents
|
||||
# first, so when both text and binary are present, binary is returned.
|
||||
assert content == data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSkill tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkill:
|
||||
"""Tests for MCPSkill."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_fetches_and_caches(self) -> None:
|
||||
client = _make_client(**{"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD)})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
content1 = await skill.get_content()
|
||||
content2 = await skill.get_content()
|
||||
|
||||
assert "Body content here." in content1
|
||||
assert content1 == content2
|
||||
# Only one MCP call should be made (cached)
|
||||
assert client.read_resource.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_raises_on_empty(self) -> None:
|
||||
client = _make_client(**{"skill://empty/SKILL.md": _make_empty_result()})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="empty-skill", description="Empty skill.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://empty/SKILL.md", client=client)
|
||||
|
||||
with pytest.raises(ValueError, match="no text content"):
|
||||
await skill.get_content()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_text(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource("references/checklist.md")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == "- check thing 1\n- check thing 2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_binary(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource("assets/icon.bin")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_unknown_returns_none(self) -> None:
|
||||
client = _make_client(**{"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD)})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource("references/does-not-exist.md")
|
||||
assert resource is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"../escape.md",
|
||||
"references/../../escape.md",
|
||||
"..",
|
||||
"..\\escape.md",
|
||||
"/etc/passwd",
|
||||
"http://attacker.example.com/payload",
|
||||
],
|
||||
)
|
||||
async def test_get_resource_path_traversal_returns_none(self, name: str) -> None:
|
||||
# Register a permissive mock that would happily return content for any URI,
|
||||
# so the test fails unless the client-side validation rejects the name
|
||||
# before issuing the read.
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(return_value=_make_text_result("should never be returned"))
|
||||
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource(name)
|
||||
assert resource is None
|
||||
client.read_resource.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_empty_name_returns_none(self) -> None:
|
||||
client = _make_client()
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
|
||||
assert await skill.get_resource("") is None
|
||||
assert await skill.get_resource(" ") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_script_returns_none(self) -> None:
|
||||
client = _make_client()
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
|
||||
assert await skill.get_script("anything") is None
|
||||
|
||||
def test_compute_skill_root_uri_strips_suffix(self) -> None:
|
||||
assert MCPSkill._compute_skill_root_uri("skill://unit-converter/SKILL.md") == "skill://unit-converter/"
|
||||
|
||||
def test_compute_skill_root_uri_trailing_slash(self) -> None:
|
||||
assert MCPSkill._compute_skill_root_uri("skill://unit-converter/") == "skill://unit-converter/"
|
||||
|
||||
def test_compute_skill_root_uri_no_suffix_adds_slash(self) -> None:
|
||||
assert MCPSkill._compute_skill_root_uri("skill://unit-converter") == "skill://unit-converter/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSkillsSource tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkillsSource:
|
||||
"""Tests for MCPSkillsSource."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_based_discovery_returns_skill(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].frontmatter.name == "unit-converter"
|
||||
assert skills[0].frontmatter.description == "Convert between common units."
|
||||
|
||||
# Content is fetched on demand, not during discovery
|
||||
content = await skills[0].get_content()
|
||||
assert "Body content here." in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_index_returns_empty(self) -> None:
|
||||
client = _make_client() # No resources at all
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_read_skill_md_during_discovery(self) -> None:
|
||||
# Index points to a skill, but SKILL.md is not registered on the server.
|
||||
# Discovery should succeed because it only reads the index.
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].frontmatter.name == "unit-converter"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_name_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter", # Invalid: uppercase
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_fields_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
# Missing description and url
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_index_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_index_json_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_text_resource(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("references/checklist.md")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == "- check thing 1\n- check thing 2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_binary_resource(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("assets/icon.bin")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# McpError code branching tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkillsSourceErrorCodeBranching:
|
||||
"""Tests that MCPSkillsSource and MCPSkill branch on McpError.error.code.
|
||||
|
||||
Only "not found" codes (RESOURCE_NOT_FOUND -32002, METHOD_NOT_FOUND -32601)
|
||||
should be silently swallowed as "no skills available." Other McpError codes
|
||||
and non-McpError exceptions must propagate so that auth failures, server
|
||||
crashes, and connection drops are visible.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_method_not_found_returns_empty(self) -> None:
|
||||
"""METHOD_NOT_FOUND (-32601) -> server doesn't support resources/read."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32601, message="Method not found")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_resource_not_found_returns_empty(self) -> None:
|
||||
"""MCP-spec "Resource not found" (-32002) -> server has no index."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=-32002, message="Resource not found"))
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_invalid_params_propagates(self) -> None:
|
||||
"""INVALID_PARAMS (-32602) is a real bug, must propagate (not "not found")."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32602, message="Invalid params")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_internal_error_propagates(self) -> None:
|
||||
"""INTERNAL_ERROR (-32603) must propagate, not silently return empty."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32603, message="Internal error")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_connection_closed_propagates(self) -> None:
|
||||
"""CONNECTION_CLOSED (-32000) must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=-32000, message="Connection closed"))
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_generic_error_code_propagates(self) -> None:
|
||||
"""Generic handler error (code 0) must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=0, message="Some handler error")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_non_mcp_error_propagates(self) -> None:
|
||||
"""Non-McpError exceptions (connection drop, timeout) must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=ConnectionError("connection lost"))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(ConnectionError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_internal_error_propagates(self) -> None:
|
||||
"""McpError with INTERNAL_ERROR on get_resource must propagate."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32603, message="Server crashed")))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(McpError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_not_found_returns_none(self) -> None:
|
||||
"""McpError with RESOURCE_NOT_FOUND (-32002) on get_resource returns None."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=-32002, message="Resource not found"))
|
||||
)
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
result = await skill.get_resource("references/file.md")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_connection_error_propagates(self) -> None:
|
||||
"""A plain ConnectionError on get_resource must propagate, not return None."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=ConnectionError("connection lost"))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(ConnectionError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_timeout_error_propagates(self) -> None:
|
||||
"""A TimeoutError on get_resource must propagate, not return None."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=TimeoutError("read timed out"))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(TimeoutError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_generic_mcp_error_propagates(self) -> None:
|
||||
"""McpError with a generic code (0) on get_resource must propagate."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=0, message="Handler error"))
|
||||
)
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(McpError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_timeout_error_propagates(self) -> None:
|
||||
"""A TimeoutError reading skill://index.json must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=TimeoutError("read timed out"))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(TimeoutError):
|
||||
await source.get_skills()
|
||||
@@ -1,4 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Annotated, Any, Literal, get_args, get_origin
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -1346,6 +1348,45 @@ async def test_invoke_skip_parsing_awaits_async_functions() -> None:
|
||||
assert raw == 42
|
||||
|
||||
|
||||
async def test_invoke_sync_tool_does_not_block_event_loop() -> None:
|
||||
release_tool = threading.Event()
|
||||
tool_thread_ids: list[int] = []
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
|
||||
@tool
|
||||
def wait_for_release() -> str:
|
||||
tool_thread_ids.append(threading.get_ident())
|
||||
return "released" if release_tool.wait(timeout=0.2) else "timed out"
|
||||
|
||||
async def release_soon() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
release_tool.set()
|
||||
|
||||
tool_task = asyncio.create_task(wait_for_release.invoke(skip_parsing=True))
|
||||
release_task = asyncio.create_task(release_soon())
|
||||
|
||||
assert await asyncio.wait_for(tool_task, timeout=1) == "released"
|
||||
await release_task
|
||||
assert tool_thread_ids
|
||||
assert tool_thread_ids[0] != event_loop_thread_id
|
||||
|
||||
|
||||
async def test_invoke_sync_tool_can_stay_on_event_loop() -> None:
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
tool_thread_ids: list[int] = []
|
||||
|
||||
@tool
|
||||
def needs_event_loop() -> str:
|
||||
tool_thread_ids.append(threading.get_ident())
|
||||
asyncio.get_running_loop()
|
||||
return "ok"
|
||||
|
||||
needs_event_loop._invoke_sync_on_event_loop = True
|
||||
|
||||
assert await needs_event_loop.invoke(skip_parsing=True) == "ok"
|
||||
assert tool_thread_ids == [event_loop_thread_id]
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
@@ -191,6 +191,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw Foundry Agent client.
|
||||
|
||||
@@ -211,6 +212,8 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
compaction_strategy: Optional per-client compaction override.
|
||||
tokenizer: Optional tokenizer for compaction strategies.
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
settings = load_settings(
|
||||
FoundryAgentSettings,
|
||||
@@ -260,8 +263,11 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
openai_client_kwargs["default_headers"] = dict(default_headers)
|
||||
if allow_preview:
|
||||
openai_client_kwargs["agent_name"] = self.agent_name
|
||||
openai_client = self.project_client.get_openai_client(**openai_client_kwargs)
|
||||
if timeout is not None:
|
||||
openai_client = openai_client.with_options(timeout=timeout)
|
||||
super().__init__(
|
||||
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
|
||||
async_client=openai_client,
|
||||
default_headers=default_headers,
|
||||
instruction_role=instruction_role,
|
||||
compaction_strategy=compaction_strategy,
|
||||
@@ -537,6 +543,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent client with full middleware support.
|
||||
|
||||
@@ -556,6 +563,8 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional sequence of middleware.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
super().__init__(
|
||||
project_endpoint=project_endpoint,
|
||||
@@ -573,6 +582,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
@@ -625,6 +635,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
additional_properties: Mapping[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent.
|
||||
|
||||
@@ -657,6 +668,8 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: Optional agent-level in-run compaction override.
|
||||
tokenizer: Optional agent-level tokenizer override.
|
||||
additional_properties: Additional properties stored on the local agent wrapper.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
# Create the client
|
||||
actual_client_type = client_type or _FoundryAgentChatClient
|
||||
@@ -675,6 +688,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
"default_headers": default_headers,
|
||||
"env_file_path": env_file_path,
|
||||
"env_file_encoding": env_file_encoding,
|
||||
"timeout": timeout,
|
||||
}
|
||||
if function_invocation_configuration is not None:
|
||||
if not issubclass(actual_client_type, FunctionInvocationLayer):
|
||||
@@ -912,6 +926,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
additional_properties: Mapping[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent with full middleware and telemetry.
|
||||
|
||||
@@ -958,6 +973,8 @@ class FoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: Optional agent-level in-run compaction override.
|
||||
tokenizer: Optional agent-level tokenizer override.
|
||||
additional_properties: Additional properties stored on the local agent wrapper.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
super().__init__(
|
||||
project_endpoint=project_endpoint,
|
||||
@@ -983,4 +1000,5 @@ class FoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
additional_properties=additional_properties,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-openai>=1.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"agent-framework-openai>=1.8.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -109,9 +109,67 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() -> None:
|
||||
"""Test that timeout is applied via with_options without mutating the shared OpenAI client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=60.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged() -> None:
|
||||
"""Test that timeout=None does not call with_options and leaves the shared client intact."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_not_called()
|
||||
assert openai_client_mock.timeout == 5.0
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled() -> None:
|
||||
"""Test that timeout uses with_options even when allow_preview=True (hosted agent path)."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
allow_preview=True,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=120.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None:
|
||||
"""Test that as_agent() wraps the client in FoundryAgent using the same client class."""
|
||||
|
||||
@@ -552,9 +610,29 @@ def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_foundry_agent_chat_client_init_propagates_timeout() -> None:
|
||||
"""Test that _FoundryAgentChatClient calls with_options instead of mutating the shared client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
client = _FoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=45.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=45.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_creates_client() -> None:
|
||||
"""Test that RawFoundryAgent creates a client internally."""
|
||||
|
||||
@@ -629,6 +707,7 @@ def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
@@ -641,9 +720,47 @@ def test_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None:
|
||||
"""Test that FoundryAgent uses with_options instead of mutating the shared OpenAI client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
agent = FoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=90.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=90.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert agent.client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_foundry_agent_init_timeout_none_leaves_client_default() -> None:
|
||||
"""Test that FoundryAgent with timeout=None does not call with_options or mutate the client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
FoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_not_called()
|
||||
assert openai_client_mock.timeout == 5.0
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None:
|
||||
"""Test that invalid client_type raises TypeError."""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import tempfile
|
||||
import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
@@ -264,28 +264,73 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
|
||||
|
||||
# Foundry Toolbox Auth integration
|
||||
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
|
||||
CONSENT_ERROR_CODE = -32007
|
||||
CONSENT_ERROR_CODE = -32006
|
||||
|
||||
|
||||
def consent_url_from_error(exc: BaseException) -> str | None:
|
||||
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
|
||||
@dataclass
|
||||
class ConsentError:
|
||||
name: str
|
||||
consent_url: str
|
||||
|
||||
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
|
||||
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
|
||||
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
|
||||
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
|
||||
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
|
||||
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
|
||||
|
||||
def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None:
|
||||
"""Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
|
||||
The consent URL(s) extracted from the error, or ``None`` if no consent error was found.
|
||||
"""
|
||||
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
|
||||
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
|
||||
return inner_exception.error.message
|
||||
# Parse the error message
|
||||
# The error message is structured with the following format:
|
||||
# "tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {"errors":[{"name": ..."
|
||||
# where the second part is a JSON string that can be deserialized into an object with the following shape:
|
||||
# ruff: disable[ERA001]
|
||||
# {
|
||||
# "errors" : [
|
||||
# {
|
||||
# "name": "Name of the MCP tool that requires consent",
|
||||
# "type" : "mcp",
|
||||
# "error": {
|
||||
# "code": "CONSENT_REQUIRED",
|
||||
# "message": consent_url,
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ruff: enable[ERA001]
|
||||
try:
|
||||
consent_errors: list[ConsentError] = []
|
||||
error_message_start = inner_exception.error.message.find("{")
|
||||
if error_message_start == -1:
|
||||
logger.warning("Consent error message does not contain JSON: %s", inner_exception.error.message)
|
||||
return None
|
||||
consent_details_json = inner_exception.error.message[error_message_start:]
|
||||
consent_details = json.loads(consent_details_json)
|
||||
if "errors" not in consent_details or not isinstance(consent_details["errors"], list):
|
||||
logger.warning("Consent error message JSON does not contain 'errors' list: %s", consent_details_json)
|
||||
return None
|
||||
for error in consent_details["errors"]:
|
||||
if (
|
||||
isinstance(error, dict)
|
||||
and error.get("type") == "mcp" # type: ignore
|
||||
and "error" in error
|
||||
and isinstance(error["error"], dict)
|
||||
and error["error"].get("code") == "CONSENT_REQUIRED" # type: ignore
|
||||
and "message" in error["error"]
|
||||
):
|
||||
consent_url = error["error"]["message"] # type: ignore
|
||||
if isinstance(consent_url, str):
|
||||
consent_errors.append(ConsentError(name=error.get("name", "Unknown"), consent_url=consent_url)) # type: ignore
|
||||
else:
|
||||
logger.warning("Consent URL in error message is not a valid URL: %s", consent_url) # type: ignore
|
||||
if consent_errors:
|
||||
return consent_errors
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse consent details JSON: %s", inner_exception.error.message)
|
||||
return None
|
||||
|
||||
|
||||
@@ -448,18 +493,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
try:
|
||||
await self._ensure_agent_ready()
|
||||
except AgentFrameworkException as ex:
|
||||
consent_url = consent_url_from_error(ex)
|
||||
if consent_url is None:
|
||||
consent_errors = consent_url_from_error(ex)
|
||||
if consent_errors is None:
|
||||
raise
|
||||
logger.warning("OAuth consent required for Foundry MCP gateway.")
|
||||
oauth_item = OAuthConsentRequestOutputItem(
|
||||
id=IdGenerator.new_id("oacr"),
|
||||
consent_link=consent_url,
|
||||
server_label="Foundry Toolbox",
|
||||
)
|
||||
builder = response_event_stream.add_output_item(oauth_item.id)
|
||||
yield builder.emit_added(oauth_item)
|
||||
yield builder.emit_done(oauth_item)
|
||||
for consent_error in consent_errors:
|
||||
logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url)
|
||||
oauth_item = OAuthConsentRequestOutputItem(
|
||||
id=IdGenerator.new_id("oacr"),
|
||||
consent_link=consent_error.consent_url,
|
||||
server_label=consent_error.name,
|
||||
)
|
||||
builder = response_event_stream.add_output_item(oauth_item.id)
|
||||
yield builder.emit_added(oauth_item)
|
||||
yield builder.emit_done(oauth_item)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260528"
|
||||
version = "1.0.0a260604"
|
||||
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.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b7,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
@@ -39,6 +39,7 @@ from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import (
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage]
|
||||
CONSENT_ERROR_CODE,
|
||||
ConsentError,
|
||||
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -2118,15 +2119,11 @@ class TestMultiTurnMixedContent:
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
mcp_call_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"
|
||||
]
|
||||
mcp_call_contents = [c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"]
|
||||
mcp_result_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result"
|
||||
]
|
||||
function_result_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "function_result"
|
||||
]
|
||||
function_result_contents = [c for m in second_call_messages for c in m.contents if c.type == "function_result"]
|
||||
|
||||
assert len(mcp_call_contents) >= 1
|
||||
assert len(mcp_result_contents) >= 1
|
||||
@@ -3264,7 +3261,10 @@ class TestCheckpointContextPathValidation:
|
||||
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
|
||||
|
||||
|
||||
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
|
||||
def _make_consent_error(
|
||||
url: str = "https://consent.example.com/auth",
|
||||
name: str = "Foundry Toolbox",
|
||||
) -> Exception:
|
||||
"""Build an exception wrapping a Foundry MCP gateway consent error.
|
||||
|
||||
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
|
||||
@@ -3272,17 +3272,34 @@ def _make_consent_error(url: str = "https://consent.example.com/auth") -> Except
|
||||
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
|
||||
original error attached via ``inner_exception``. ``consent_url_from_error``
|
||||
then finds the wrapped ``McpError`` in ``exc.args``.
|
||||
|
||||
The McpError message uses the structured Foundry MCP gateway format:
|
||||
a human-readable prefix followed by a JSON document describing each
|
||||
failed tool source and its consent URL.
|
||||
"""
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
|
||||
payload = json.dumps({
|
||||
"errors": [
|
||||
{
|
||||
"name": name,
|
||||
"type": "mcp",
|
||||
"error": {
|
||||
"code": "CONSENT_REQUIRED",
|
||||
"message": url,
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
message = f"tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {payload}"
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=message))
|
||||
return ToolExecutionException("MCP consent required", inner_exception=inner)
|
||||
|
||||
|
||||
class TestConsentUrlFromError:
|
||||
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
|
||||
exc = _make_consent_error("https://example.com/consent")
|
||||
assert consent_url_from_error(exc) == "https://example.com/consent"
|
||||
exc = _make_consent_error("https://example.com/consent", name="my-tool")
|
||||
assert consent_url_from_error(exc) == [ConsentError(name="my-tool", consent_url="https://example.com/consent")]
|
||||
|
||||
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
|
||||
assert consent_url_from_error(Exception("boom")) is None
|
||||
@@ -3299,6 +3316,13 @@ class TestConsentUrlFromError:
|
||||
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
|
||||
assert consent_url_from_error(bare) is None
|
||||
|
||||
def test_returns_none_when_message_has_no_json(self) -> None:
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="no json here"))
|
||||
exc = ToolExecutionException("MCP consent required", inner_exception=inner)
|
||||
assert consent_url_from_error(exc) is None
|
||||
|
||||
|
||||
class TestAgentLifecycle:
|
||||
async def test_agent_entered_lazily_on_first_request(self) -> None:
|
||||
|
||||
@@ -37,9 +37,10 @@ from agent_framework.exceptions import AgentException
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
try:
|
||||
from copilot import CopilotClient, CopilotSession, SubprocessConfig
|
||||
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
|
||||
from copilot import CopilotClient, CopilotSession, RuntimeConnection
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
from copilot.session import MCPServerConfig, PermissionRequestResult, ProviderConfig, SystemMessageConfig
|
||||
from copilot.session_events import PermissionRequest, SessionEvent, SessionEventType
|
||||
from copilot.tools import Tool as CopilotTool
|
||||
from copilot.tools import ToolInvocation, ToolResult
|
||||
except ImportError as _copilot_import_error:
|
||||
@@ -57,8 +58,10 @@ else:
|
||||
DEFAULT_TIMEOUT_SECONDS: float = 60.0
|
||||
"""Default timeout in seconds for Copilot requests."""
|
||||
|
||||
PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult]
|
||||
"""Type for permission request handlers."""
|
||||
PermissionHandlerType = Callable[
|
||||
[PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
|
||||
]
|
||||
"""Type for permission request handlers. Supports both sync and async callbacks."""
|
||||
|
||||
|
||||
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
|
||||
@@ -121,7 +124,7 @@ def _deny_all_permissions(
|
||||
_invocation: dict[str, str],
|
||||
) -> PermissionRequestResult:
|
||||
"""Default permission handler that denies all requests."""
|
||||
return PermissionRequestResult()
|
||||
return PermissionDecisionUserNotAvailable()
|
||||
|
||||
|
||||
class GitHubCopilotSettings(TypedDict, total=False):
|
||||
@@ -140,9 +143,9 @@ class GitHubCopilotSettings(TypedDict, total=False):
|
||||
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
|
||||
log_level: CLI log level.
|
||||
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
|
||||
copilot_home: Directory where the CLI stores session state, configuration,
|
||||
base_directory: Directory where the CLI stores session state, configuration,
|
||||
and other persistent data. Can be set via environment variable
|
||||
GITHUB_COPILOT_COPILOT_HOME. Defaults to ~/.copilot when not set.
|
||||
GITHUB_COPILOT_BASE_DIRECTORY. Defaults to ~/.copilot when not set.
|
||||
Only applicable when the SDK spawns the CLI process (ignored when
|
||||
connecting to an external server via a pre-configured client).
|
||||
"""
|
||||
@@ -151,7 +154,7 @@ class GitHubCopilotSettings(TypedDict, total=False):
|
||||
model: str | None
|
||||
timeout: float | None
|
||||
log_level: str | None
|
||||
copilot_home: str | None
|
||||
base_directory: str | None
|
||||
|
||||
|
||||
class GitHubCopilotOptions(TypedDict, total=False):
|
||||
@@ -314,7 +317,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
provider: ProviderConfig | None = opts.pop("provider", None)
|
||||
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
|
||||
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
|
||||
copilot_home = opts.pop("copilot_home", None)
|
||||
base_directory = opts.pop("base_directory", None)
|
||||
|
||||
self._settings = load_settings(
|
||||
GitHubCopilotSettings,
|
||||
@@ -323,7 +326,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
log_level=log_level,
|
||||
copilot_home=copilot_home,
|
||||
base_directory=base_directory,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
@@ -362,14 +365,16 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
if self._client is None:
|
||||
cli_path = self._settings.get("cli_path") or None
|
||||
log_level = self._settings.get("log_level") or None
|
||||
copilot_home = self._settings.get("copilot_home") or None
|
||||
base_directory = self._settings.get("base_directory") or None
|
||||
|
||||
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
|
||||
client_kwargs: dict[str, Any] = {}
|
||||
if cli_path:
|
||||
client_kwargs["connection"] = RuntimeConnection.for_stdio(path=cli_path)
|
||||
if log_level:
|
||||
subprocess_kwargs["log_level"] = log_level
|
||||
if copilot_home:
|
||||
subprocess_kwargs["copilot_home"] = copilot_home
|
||||
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))
|
||||
client_kwargs["log_level"] = log_level
|
||||
if base_directory:
|
||||
client_kwargs["base_directory"] = base_directory
|
||||
self._client = CopilotClient(**client_kwargs)
|
||||
|
||||
try:
|
||||
await self._client.start()
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0rc1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# ruff: noqa: E402
|
||||
|
||||
import os
|
||||
import unittest.mock
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -20,9 +21,11 @@ from agent_framework import (
|
||||
ContextProvider,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from copilot.generated.session_events import (
|
||||
from copilot.session import PermissionHandler
|
||||
from copilot.session_events import (
|
||||
Data,
|
||||
SessionEvent,
|
||||
SessionEventType,
|
||||
@@ -308,27 +311,27 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
)
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.cli_path == "/custom/path"
|
||||
assert call_args.log_level == "debug"
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert kwargs["connection"].path == "/custom/path"
|
||||
assert kwargs["log_level"] == "debug"
|
||||
|
||||
async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
|
||||
"""Test that copilot_home is passed through to SubprocessConfig."""
|
||||
async def test_start_passes_base_directory_to_client(self) -> None:
|
||||
"""Test that base_directory is passed through to CopilotClient."""
|
||||
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
mock_client.start = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"copilot_home": "/custom/copilot/home"}
|
||||
default_options={"base_directory": "/custom/copilot/home"}
|
||||
)
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.copilot_home == "/custom/copilot/home"
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert kwargs["base_directory"] == "/custom/copilot/home"
|
||||
|
||||
async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
|
||||
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
|
||||
async def test_start_base_directory_not_set_when_unspecified(self) -> None:
|
||||
"""Test that base_directory is not included in client kwargs when not specified."""
|
||||
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
mock_client.start = AsyncMock()
|
||||
@@ -337,14 +340,14 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
agent = GitHubCopilotAgent()
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.copilot_home is None
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert "base_directory" not in kwargs
|
||||
|
||||
async def test_start_copilot_home_from_env_variable(self) -> None:
|
||||
"""Test that copilot_home can be set via GITHUB_COPILOT_COPILOT_HOME env variable."""
|
||||
async def test_start_base_directory_from_env_variable(self) -> None:
|
||||
"""Test that base_directory can be set via GITHUB_COPILOT_BASE_DIRECTORY env variable."""
|
||||
with (
|
||||
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
|
||||
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
|
||||
patch.dict("os.environ", {"GITHUB_COPILOT_BASE_DIRECTORY": "/env/copilot/home"}),
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_client.start = AsyncMock()
|
||||
@@ -353,8 +356,8 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
agent = GitHubCopilotAgent()
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.copilot_home == "/env/copilot/home"
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert kwargs["base_directory"] == "/env/copilot/home"
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentRun:
|
||||
@@ -1053,11 +1056,11 @@ class TestGitHubCopilotAgentSessionManagement:
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that resumed session config includes tools and permission handler."""
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionDecisionApproveOnce()
|
||||
|
||||
def my_tool(arg: str) -> str:
|
||||
"""A test tool."""
|
||||
@@ -1869,6 +1872,15 @@ class TestGitHubCopilotAgentErrorHandling:
|
||||
class TestGitHubCopilotAgentPermissions:
|
||||
"""Test cases for permission handling."""
|
||||
|
||||
def test_deny_all_permissions_returns_user_not_available(self) -> None:
|
||||
"""Test that the default deny handler returns PermissionDecisionUserNotAvailable."""
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
|
||||
from agent_framework_github_copilot._agent import _deny_all_permissions
|
||||
|
||||
result = _deny_all_permissions(MagicMock(), {})
|
||||
assert isinstance(result, PermissionDecisionUserNotAvailable)
|
||||
|
||||
def test_no_permission_handler_when_not_provided(self) -> None:
|
||||
"""Test that no handler is set when on_permission_request is not provided."""
|
||||
agent = GitHubCopilotAgent()
|
||||
@@ -1876,13 +1888,14 @@ class TestGitHubCopilotAgentPermissions:
|
||||
|
||||
def test_permission_handler_set_when_provided(self) -> None:
|
||||
"""Test that a handler is set when on_permission_request is provided."""
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
|
||||
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
if request.kind == "shell":
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
return PermissionDecisionApproveOnce()
|
||||
return PermissionDecisionDeniedInteractivelyByUser()
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"on_permission_request": approve_shell}
|
||||
@@ -1895,13 +1908,14 @@ class TestGitHubCopilotAgentPermissions:
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that session config includes permission handler when provided."""
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
|
||||
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
if request.kind in ("shell", "read"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
return PermissionDecisionApproveOnce()
|
||||
return PermissionDecisionDeniedInteractivelyByUser()
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
@@ -2705,3 +2719,163 @@ class TestGitHubCopilotAgentContextProviders:
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — require COPILOT_GITHUB_TOKEN env var
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
skip_if_copilot_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("COPILOT_GITHUB_TOKEN", "") == "",
|
||||
reason="No COPILOT_GITHUB_TOKEN provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
return f"The weather in {location} is sunny with a high of 25C."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_simple_prompt_returns_response() -> None:
|
||||
"""Integration test: basic non-streaming response."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
response = await agent.run("What is 2 + 2? Answer with just the number.", session=session)
|
||||
|
||||
assert response is not None
|
||||
assert len(response.messages) > 0
|
||||
assert "4" in response.text
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_streaming_returns_updates() -> None:
|
||||
"""Integration test: streaming response yields updates."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
updates = []
|
||||
async for chunk in agent.run("Count from 1 to 5.", stream=True, session=session):
|
||||
updates.append(chunk)
|
||||
|
||||
assert len(updates) > 0
|
||||
full_text = "".join(u.text for u in updates if u.text)
|
||||
assert len(full_text) > 0
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_function_tool_invokes_tool() -> None:
|
||||
"""Integration test: function tool is invoked by the agent."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent. Use the get_weather tool to answer weather questions.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
response = await agent.run("What's the weather like in Seattle?", session=session)
|
||||
|
||||
assert response is not None
|
||||
assert len(response.messages) > 0
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"])
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_session_maintains_context() -> None:
|
||||
"""Integration test: session maintains conversation context across turns."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
|
||||
response1 = await agent.run("My name is Alice.", session=session)
|
||||
assert response1 is not None
|
||||
|
||||
response2 = await agent.run("What is my name?", session=session)
|
||||
|
||||
assert response2 is not None
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_session_resume_continues_conversation() -> None:
|
||||
"""Integration test: session can be resumed by ID."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session1 = agent.create_session()
|
||||
await agent.run("Remember this number: 42.", session=session1)
|
||||
|
||||
session_id = session1.service_session_id
|
||||
assert session_id is not None
|
||||
|
||||
session2 = AgentSession()
|
||||
session2.service_session_id = session_id
|
||||
|
||||
response = await agent.run("What number did I ask you to remember?", session=session2)
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response.text
|
||||
|
||||
if agent._client:
|
||||
await agent._client.delete_session(session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_shell_permissions_executes_command() -> None:
|
||||
"""Integration test: shell commands can be executed with permission handler."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant that can execute shell commands.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
response = await agent.run("Run a shell command to print 'hello world'", session=session)
|
||||
|
||||
assert response is not None
|
||||
assert "hello" in response.text.lower()
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mistral AI integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260505"
|
||||
version = "1.0.0a260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"mistralai>=2.0.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw OpenAI Chat client.
|
||||
|
||||
@@ -406,6 +407,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
env_file_path: Optional ``.env`` file that is checked before the process environment
|
||||
for ``OPENAI_*`` values.
|
||||
env_file_encoding: Encoding for the ``.env`` file.
|
||||
timeout: Optional timeout in seconds for requests.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -427,6 +429,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw OpenAI Chat client.
|
||||
|
||||
@@ -455,6 +458,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
env_file_path: Optional ``.env`` file that is checked before process environment
|
||||
variables for ``AZURE_OPENAI_*`` values.
|
||||
env_file_encoding: Encoding for the ``.env`` file.
|
||||
timeout: Optional timeout in seconds for requests.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -476,6 +480,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw OpenAI Chat client.
|
||||
|
||||
@@ -511,6 +516,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*``
|
||||
lookups.
|
||||
env_file_encoding: Encoding for the ``.env`` file.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
|
||||
Notes:
|
||||
Environment resolution and routing precedence are:
|
||||
@@ -541,6 +548,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
openai_model_fields=("chat_model", "model"),
|
||||
azure_model_fields=("chat_model", "model"),
|
||||
responses_mode=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
self.client = client
|
||||
@@ -1454,10 +1462,21 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
Returns:
|
||||
The prepared chat messages for a request.
|
||||
"""
|
||||
drops_reasoning_without_storage = not request_uses_service_side_storage and any(
|
||||
content.type == "text_reasoning" for message in chat_messages for content in message.contents
|
||||
)
|
||||
drop_mcp_call_ids: set[str] = set()
|
||||
if drops_reasoning_without_storage:
|
||||
for message in chat_messages:
|
||||
for content in message.contents:
|
||||
if content.type == "mcp_server_tool_call" and content.call_id:
|
||||
drop_mcp_call_ids.add(content.call_id)
|
||||
|
||||
list_of_list = [
|
||||
self._prepare_message_for_openai(
|
||||
message,
|
||||
request_uses_service_side_storage=request_uses_service_side_storage,
|
||||
drop_mcp_call_ids=drop_mcp_call_ids,
|
||||
)
|
||||
for message in chat_messages
|
||||
]
|
||||
@@ -1472,6 +1491,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
message: Message,
|
||||
*,
|
||||
request_uses_service_side_storage: bool = True,
|
||||
drop_mcp_call_ids: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Prepare a chat message for the OpenAI Responses API format."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
@@ -1491,7 +1511,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
# (replays_local_storage) still need stripping when the request also carries a continuation
|
||||
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
|
||||
# storage, standalone reasoning items are invalid per the API ("reasoning was provided
|
||||
# without its required following item"), so the reasoning branch always drops.
|
||||
# without its required following item"), so the reasoning branch always drops. When that
|
||||
# happens, `_prepare_messages_for_openai` also drops the paired hosted-MCP IDs across
|
||||
# message boundaries rather than replaying bare MCP items.
|
||||
drop_mcp_call_ids = drop_mcp_call_ids or set()
|
||||
for content in message.contents:
|
||||
match content.type:
|
||||
case "text_reasoning":
|
||||
@@ -1546,7 +1569,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
# server-side `id`, so under continuation it would duplicate
|
||||
# the prior response's items (#3295). Drop the call here; the
|
||||
# orphan result is dropped by the coalesce step that follows.
|
||||
if request_uses_service_side_storage:
|
||||
#
|
||||
# Without storage, a reasoning + hosted-MCP pair cannot be replayed
|
||||
# partially: reasoning is stripped above, and a bare mcp_call is rejected.
|
||||
if request_uses_service_side_storage or content.call_id in drop_mcp_call_ids:
|
||||
continue
|
||||
prepared_mcp = self._prepare_content_for_openai(
|
||||
message.role,
|
||||
|
||||
@@ -162,6 +162,7 @@ def load_openai_service_settings(
|
||||
openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",),
|
||||
azure_model_fields: Sequence[OpenAIModelSettingName] = ("model",),
|
||||
responses_mode: bool = False,
|
||||
timeout: float | None = None,
|
||||
) -> tuple[dict[str, Any], AsyncOpenAI, bool]:
|
||||
"""Load OpenAI settings, including Azure OpenAI model aliases.
|
||||
|
||||
@@ -218,6 +219,8 @@ def load_openai_service_settings(
|
||||
}
|
||||
if base_url := openai_settings.get("base_url"):
|
||||
client_args["base_url"] = base_url
|
||||
if timeout is not None:
|
||||
client_args["timeout"] = timeout
|
||||
return openai_settings, AsyncOpenAI(**client_args), False # type: ignore[return-value]
|
||||
checked_openai = True
|
||||
azure_settings = load_settings(
|
||||
@@ -299,8 +302,12 @@ def load_openai_service_settings(
|
||||
openai_args["api_key"] = _ensure_async_token_provider(client_args["azure_ad_token_provider"])
|
||||
elif "api_key" in client_args:
|
||||
openai_args["api_key"] = client_args["api_key"]
|
||||
if timeout is not None:
|
||||
openai_args["timeout"] = timeout
|
||||
return azure_settings, AsyncOpenAI(**openai_args), True # type: ignore[return-value]
|
||||
|
||||
if timeout is not None:
|
||||
client_args["timeout"] = timeout
|
||||
return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
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.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from agent_framework.exceptions import (
|
||||
ChatClientInvalidRequestException,
|
||||
SettingNotFoundError,
|
||||
)
|
||||
from openai import BadRequestError
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
from openai.types.responses.response_reasoning_item import Summary
|
||||
from openai.types.responses.response_reasoning_summary_text_delta_event import (
|
||||
ResponseReasoningSummaryTextDeltaEvent,
|
||||
@@ -55,7 +55,7 @@ from pydantic import BaseModel
|
||||
from pytest import param
|
||||
|
||||
from agent_framework_openai import OpenAIChatClient
|
||||
from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
|
||||
from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY, RawOpenAIChatClient
|
||||
from agent_framework_openai._exceptions import OpenAIContentFilterException
|
||||
|
||||
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
|
||||
@@ -194,6 +194,26 @@ def test_init_uses_explicit_parameters() -> None:
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_raw_openai_chat_client_init_uses_explicit_parameters() -> None:
|
||||
signature = inspect.signature(RawOpenAIChatClient.__init__)
|
||||
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_raw_openai_chat_client_accepts_preconfigured_client_with_timeout() -> None:
|
||||
"""Test that timeout is accepted without error when async_client is pre-provided."""
|
||||
|
||||
mock_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_client.timeout = 5.0
|
||||
|
||||
client = RawOpenAIChatClient(async_client=mock_client, timeout=30.0)
|
||||
assert client is not None
|
||||
|
||||
|
||||
def test_openai_chat_client_supports_all_tool_protocols() -> None:
|
||||
assert isinstance(OpenAIChatClient, SupportsCodeInterpreterTool)
|
||||
assert isinstance(OpenAIChatClient, SupportsWebSearchTool)
|
||||
@@ -5648,6 +5668,79 @@ def test_prepare_messages_for_openai_coalesces_mcp_call_and_result_into_single_i
|
||||
assert fco_items == [], f"unexpected orphan function_call_output items: {fco_items}"
|
||||
|
||||
|
||||
def test_prepare_messages_for_openai_drops_mcp_call_when_paired_reasoning_is_stripped() -> None:
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text_reasoning(id="rs_abc123", text="Need the MCP server."),
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q": "cats"}',
|
||||
),
|
||||
],
|
||||
),
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_abc123",
|
||||
output=[Content.from_text(text="found 10 cats")],
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
|
||||
|
||||
types = [item.get("type") for item in result if isinstance(item, dict)]
|
||||
assert "reasoning" not in types
|
||||
assert "mcp_call" not in types
|
||||
assert "function_call_output" not in types
|
||||
|
||||
|
||||
def test_prepare_messages_for_openai_drops_mcp_call_across_reasoning_messages() -> None:
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text_reasoning(id="rs_abc123", text="Need a tool call.")],
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q": "cats"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_abc123",
|
||||
output=[Content.from_text(text="found 10 cats")],
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
|
||||
|
||||
types = [item.get("type") for item in result if isinstance(item, dict)]
|
||||
assert "reasoning" not in types
|
||||
assert "mcp_call" not in types
|
||||
assert "function_call_output" not in types
|
||||
|
||||
|
||||
def test_prepare_messages_for_openai_drops_orphan_mcp_server_tool_result() -> None:
|
||||
"""When an mcp_server_tool_result has no matching mcp_server_tool_call in
|
||||
the message list, it must be dropped, NOT serialized as a
|
||||
|
||||
@@ -19,7 +19,7 @@ from typing_extensions import Never
|
||||
|
||||
from ._orchestration_request_info import AgentApprovalExecutor
|
||||
from ._participant_output_config import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
UNSET,
|
||||
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -213,7 +213,7 @@ class ConcurrentBuilder:
|
||||
*,
|
||||
participants: Sequence[SupportsAgentRun | Executor],
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
|
||||
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
|
||||
) -> None:
|
||||
"""Initialize the ConcurrentBuilder.
|
||||
|
||||
@@ -52,7 +52,7 @@ from ._base_group_chat_orchestrator import (
|
||||
from ._orchestration_request_info import AgentApprovalExecutor
|
||||
from ._orchestrator_helpers import clean_conversation_for_handoff
|
||||
from ._participant_output_config import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
UNSET,
|
||||
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -626,7 +626,7 @@ class GroupChatBuilder:
|
||||
termination_condition: TerminationCondition | None = None,
|
||||
max_rounds: int | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
|
||||
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
|
||||
) -> None:
|
||||
"""Initialize the GroupChatBuilder.
|
||||
|
||||
@@ -54,7 +54,7 @@ from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
from ._base_group_chat_orchestrator import TerminationCondition
|
||||
from ._orchestrator_helpers import clean_conversation_for_handoff
|
||||
from ._participant_output_config import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
UNSET,
|
||||
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -597,7 +597,7 @@ class HandoffBuilder:
|
||||
description: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
termination_condition: TerminationCondition | None = None,
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
|
||||
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
|
||||
) -> None:
|
||||
r"""Initialize a HandoffBuilder for creating conversational handoff workflows.
|
||||
|
||||
@@ -28,7 +28,7 @@ from agent_framework._workflows._request_info_mixin import response_handler
|
||||
from agent_framework._workflows._workflow import Workflow
|
||||
from agent_framework._workflows._workflow_builder import WorkflowBuilder
|
||||
from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
from typing_extensions import Never
|
||||
from typing_extensions import Never, Sentinel
|
||||
|
||||
from ._base_group_chat_orchestrator import (
|
||||
BaseGroupChatOrchestrator,
|
||||
@@ -39,7 +39,7 @@ from ._base_group_chat_orchestrator import (
|
||||
ParticipantRegistry,
|
||||
)
|
||||
from ._participant_output_config import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
UNSET,
|
||||
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -1411,13 +1411,13 @@ class MagenticBuilder:
|
||||
task_ledger_plan_update_prompt: str | None = None,
|
||||
progress_ledger_prompt: str | None = None,
|
||||
final_answer_prompt: str | None = None,
|
||||
max_stall_count: int = 3,
|
||||
max_stall_count: int | Sentinel = UNSET,
|
||||
max_reset_count: int | None = None,
|
||||
max_round_count: int | None = None,
|
||||
# Existing params
|
||||
enable_plan_review: bool = False,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
|
||||
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
|
||||
) -> None:
|
||||
"""Initialize the Magentic workflow builder.
|
||||
@@ -1621,7 +1621,7 @@ class MagenticBuilder:
|
||||
progress_ledger_prompt: str | None = None,
|
||||
final_answer_prompt: str | None = None,
|
||||
# Limits
|
||||
max_stall_count: int = 3,
|
||||
max_stall_count: int | Sentinel = UNSET,
|
||||
max_reset_count: int | None = None,
|
||||
max_round_count: int | None = None,
|
||||
) -> None:
|
||||
@@ -1656,8 +1656,10 @@ class MagenticBuilder:
|
||||
"Exactly one of manager, manager_agent, manager_factory, or manager_agent_factory must be provided."
|
||||
)
|
||||
|
||||
resolved_max_stall_count: int = 3 if max_stall_count is UNSET else cast(int, max_stall_count)
|
||||
|
||||
def _log_warning_if_constructor_args_provided() -> None:
|
||||
if any(
|
||||
if max_stall_count is not UNSET or any(
|
||||
arg is not None
|
||||
for arg in [
|
||||
task_ledger,
|
||||
@@ -1668,7 +1670,6 @@ class MagenticBuilder:
|
||||
task_ledger_plan_update_prompt,
|
||||
progress_ledger_prompt,
|
||||
final_answer_prompt,
|
||||
max_stall_count,
|
||||
max_reset_count,
|
||||
max_round_count,
|
||||
]
|
||||
@@ -1689,7 +1690,7 @@ class MagenticBuilder:
|
||||
task_ledger_plan_update_prompt=task_ledger_plan_update_prompt,
|
||||
progress_ledger_prompt=progress_ledger_prompt,
|
||||
final_answer_prompt=final_answer_prompt,
|
||||
max_stall_count=max_stall_count,
|
||||
max_stall_count=resolved_max_stall_count,
|
||||
max_reset_count=max_reset_count,
|
||||
max_round_count=max_round_count,
|
||||
)
|
||||
@@ -1707,7 +1708,7 @@ class MagenticBuilder:
|
||||
"task_ledger_plan_update_prompt": task_ledger_plan_update_prompt,
|
||||
"progress_ledger_prompt": progress_ledger_prompt,
|
||||
"final_answer_prompt": final_answer_prompt,
|
||||
"max_stall_count": max_stall_count,
|
||||
"max_stall_count": resolved_max_stall_count,
|
||||
"max_reset_count": max_reset_count,
|
||||
"max_round_count": max_round_count,
|
||||
}
|
||||
|
||||
+4
-3
@@ -8,8 +8,9 @@ from typing import Any, Literal
|
||||
from agent_framework import SupportsAgentRun
|
||||
from agent_framework._workflows._agent_utils import resolve_agent_id
|
||||
from agent_framework._workflows._executor import Executor
|
||||
from typing_extensions import Sentinel
|
||||
|
||||
_MISSING = object()
|
||||
UNSET = Sentinel("UNSET")
|
||||
_ALL_OUTPUTS: Literal["all"] = "all"
|
||||
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
|
||||
_ParticipantOutputSpecifier = str | SupportsAgentRun | Executor
|
||||
@@ -20,10 +21,10 @@ _WorkflowExecutorSpecifier = Executor | SupportsAgentRun
|
||||
|
||||
def _coalesce_output_from( # pyright: ignore[reportUnusedFunction]
|
||||
*,
|
||||
output_from: Any = _MISSING,
|
||||
output_from: Any = UNSET,
|
||||
) -> _ParticipantOutputSelection:
|
||||
"""Resolve orchestration output selection to ``output_from``."""
|
||||
if output_from is not _MISSING:
|
||||
if output_from is not UNSET:
|
||||
return _coerce_output_from(output_from)
|
||||
return None
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
|
||||
from ._orchestration_request_info import AgentApprovalExecutor
|
||||
from ._participant_output_config import (
|
||||
_MISSING, # pyright: ignore[reportPrivateUsage]
|
||||
UNSET,
|
||||
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
|
||||
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -99,7 +99,7 @@ class SequentialBuilder:
|
||||
participants: Sequence[SupportsAgentRun | Executor],
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
chain_only_agent_responses: bool = False,
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
|
||||
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
|
||||
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
|
||||
) -> None:
|
||||
"""Initialize the SequentialBuilder.
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0rc2"
|
||||
version = "1.0.0rc3"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from dataclasses import dataclass
|
||||
@@ -987,6 +988,33 @@ def test_magentic_builder_requires_exactly_one_manager_option():
|
||||
MagenticBuilder(participants=[agent], manager=manager, manager_factory=manager_factory)
|
||||
|
||||
|
||||
def test_magentic_with_custom_manager_does_not_warn_without_standard_manager_options(caplog: Any) -> None:
|
||||
caplog.set_level(logging.WARNING, logger="agent_framework_orchestrations._magentic")
|
||||
|
||||
MagenticBuilder(participants=[StubAgent("agentA", "reply")], manager=FakeManager())
|
||||
|
||||
assert "Custom manager provided; all other manager arguments will be ignored." not in caplog.text
|
||||
|
||||
|
||||
def test_magentic_with_custom_manager_factory_does_not_warn_without_standard_manager_options(caplog: Any) -> None:
|
||||
caplog.set_level(logging.WARNING, logger="agent_framework_orchestrations._magentic")
|
||||
|
||||
def manager_factory() -> MagenticManagerBase:
|
||||
return FakeManager()
|
||||
|
||||
MagenticBuilder(participants=[StubAgent("agentA", "reply")], manager_factory=manager_factory)
|
||||
|
||||
assert "Custom manager provided; all other manager arguments will be ignored." not in caplog.text
|
||||
|
||||
|
||||
def test_magentic_with_custom_manager_warns_when_standard_manager_option_is_provided(caplog: Any) -> None:
|
||||
caplog.set_level(logging.WARNING, logger="agent_framework_orchestrations._magentic")
|
||||
|
||||
MagenticBuilder(participants=[StubAgent("agentA", "reply")], manager=FakeManager(), max_stall_count=3)
|
||||
|
||||
assert "Custom manager provided; all other manager arguments will be ignored." in caplog.text
|
||||
|
||||
|
||||
async def test_magentic_with_manager_factory():
|
||||
"""Test workflow creation using manager_factory."""
|
||||
factory_call_count = 0
|
||||
@@ -1037,6 +1065,20 @@ async def test_magentic_with_agent_factory():
|
||||
assert event_count > 0
|
||||
|
||||
|
||||
def test_magentic_agent_factory_uses_default_max_stall_count() -> None:
|
||||
def agent_factory() -> SupportsAgentRun:
|
||||
return cast(SupportsAgentRun, StubManagerAgent())
|
||||
|
||||
participant = StubAgent("agentA", "reply from agentA")
|
||||
workflow = MagenticBuilder(participants=[participant], manager_agent_factory=agent_factory).build()
|
||||
|
||||
orchestrator = next(e for e in workflow.executors.values() if isinstance(e, MagenticOrchestrator))
|
||||
manager = orchestrator._manager # type: ignore[reportPrivateUsage]
|
||||
|
||||
assert isinstance(manager, StandardMagenticManager)
|
||||
assert manager.max_stall_count == 3
|
||||
|
||||
|
||||
async def test_magentic_manager_factory_reusable_builder():
|
||||
"""Test that the builder can be reused to build multiple workflows with manager factory."""
|
||||
factory_call_count = 0
|
||||
|
||||
@@ -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.7.0"
|
||||
version = "1.8.0"
|
||||
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[all]==1.7.0",
|
||||
"agent-framework-core[all]==1.8.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -5,7 +5,8 @@ This folder demonstrates context compaction patterns introduced by ADR-0019.
|
||||
## Files
|
||||
|
||||
- `basics.py` — builds a local message list and applies each built-in strategy one at a time.
|
||||
- `advanced.py` — composes multiple strategies with `TokenBudgetComposedStrategy`.
|
||||
- `summarization.py` — runs `SummarizationStrategy` directly with a real summarizing chat client.
|
||||
- `advanced.py` — composes multiple strategies with `TokenBudgetComposedStrategy`, including a real summarizer and tool-call groups.
|
||||
- `agent_client_overrides.py` — shows client defaults, agent-level overrides, and per-run compaction overrides.
|
||||
- `custom.py` — defines a custom strategy implementing the `CompactionStrategy` protocol.
|
||||
- `tiktoken_tokenizer.py` — shows a `TokenizerProtocol` implementation backed by `tiktoken`.
|
||||
@@ -15,7 +16,8 @@ Run samples with:
|
||||
|
||||
```bash
|
||||
uv run samples/02-agents/compaction/basics.py
|
||||
uv run samples/02-agents/compaction/advanced.py
|
||||
uv run samples/02-agents/compaction/summarization.py # requires OPENAI_API_KEY
|
||||
uv run samples/02-agents/compaction/advanced.py # requires OPENAI_API_KEY
|
||||
uv run samples/02-agents/compaction/agent_client_overrides.py
|
||||
uv run samples/02-agents/compaction/custom.py
|
||||
uv run samples/02-agents/compaction/tiktoken_tokenizer.py
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
GROUP_ANNOTATION_KEY,
|
||||
GROUP_TOKEN_COUNT_KEY,
|
||||
SUMMARY_OF_MESSAGE_IDS_KEY,
|
||||
CharacterEstimatorTokenizer,
|
||||
ChatResponse,
|
||||
Content,
|
||||
Message,
|
||||
SelectiveToolCallCompactionStrategy,
|
||||
SlidingWindowStrategy,
|
||||
@@ -15,36 +18,48 @@ from agent_framework import (
|
||||
apply_compaction,
|
||||
included_token_count,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
"""This sample demonstrates composed in-run compaction with a token budget.
|
||||
load_dotenv()
|
||||
|
||||
"""This sample demonstrates composed in-run compaction under a token budget.
|
||||
|
||||
A long, tool-using conversation is compacted with a single
|
||||
``TokenBudgetComposedStrategy`` that runs three strategies in order until the
|
||||
included-token count fits the budget:
|
||||
|
||||
1. ``SelectiveToolCallCompactionStrategy`` — drop older tool-call groups
|
||||
(assistant ``function_call`` + ``tool`` result messages) that are expensive
|
||||
and rarely needed verbatim once acted upon.
|
||||
2. ``SummarizationStrategy`` — use a *real* chat client to summarize the oldest
|
||||
remaining turns into a single linked summary message.
|
||||
3. ``SlidingWindowStrategy`` — as a final guard, keep only the most recent
|
||||
groups if the budget is still exceeded.
|
||||
|
||||
Key components:
|
||||
- TokenBudgetComposedStrategy
|
||||
- Sequential strategy composition
|
||||
- Summarization with a SupportsChatGetResponse-compatible summarizer client
|
||||
- TokenBudgetComposedStrategy with ordered, escalating strategies
|
||||
- A real OpenAIChatClient used as the summarizer (not a stub)
|
||||
- Tool-call groups in the history so tool-call compaction is meaningful
|
||||
- Token accounting before/after via a TokenizerProtocol
|
||||
|
||||
Run with:
|
||||
uv run samples/02-agents/compaction/advanced.py # requires OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
|
||||
class BudgetSummaryClient:
|
||||
async def get_response(
|
||||
self,
|
||||
messages: list[Message],
|
||||
*,
|
||||
stream: bool = False,
|
||||
options: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
summary_text = f"Budget summary generated from {len(messages)} prompt messages."
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=[summary_text])])
|
||||
|
||||
|
||||
def _build_long_history() -> list[Message]:
|
||||
history = [Message(role="system", contents=["You are a migration copilot."])]
|
||||
for i in range(1, 8):
|
||||
"""Build a long, tool-using migration conversation to create token pressure."""
|
||||
history: list[Message] = [
|
||||
Message(role="system", contents=["You are a migration copilot that plans and executes database migrations."]),
|
||||
]
|
||||
|
||||
# A few verbose planning turns to build up token pressure.
|
||||
for i in range(1, 5):
|
||||
history.append(
|
||||
Message(
|
||||
role="user",
|
||||
contents=[f"Iteration {i}: capture migration requirements and edge cases."],
|
||||
contents=[f"Iteration {i}: capture migration requirements, constraints, and edge cases in detail."],
|
||||
)
|
||||
)
|
||||
history.append(
|
||||
@@ -52,17 +67,62 @@ def _build_long_history() -> list[Message]:
|
||||
role="assistant",
|
||||
contents=[
|
||||
(
|
||||
f"Iteration {i}: detailed plan with dependencies, rollback guidance, and testing details. "
|
||||
"This sentence is intentionally long to create token pressure."
|
||||
f"Iteration {i}: produced a detailed plan covering dependencies, rollback guidance, data "
|
||||
"backfill, and a full testing matrix. This response is intentionally verbose to add pressure."
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# A tool-call group: the assistant inspects the schema via a tool.
|
||||
history.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id="call_1", name="inspect_schema", arguments='{"db":"legacy"}')],
|
||||
)
|
||||
)
|
||||
history.append(
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call_1", result="tables: users, orders, invoices, events")],
|
||||
)
|
||||
)
|
||||
history.append(Message(role="assistant", contents=["Schema inspection found four core tables to migrate."]))
|
||||
|
||||
# The most recent turn — this should survive compaction verbatim.
|
||||
history.append(Message(role="user", contents=["What is the safest order to migrate these tables?"]))
|
||||
history.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=["Migrate reference tables (users) first, then orders, then invoices, and events last."],
|
||||
)
|
||||
)
|
||||
return history
|
||||
|
||||
|
||||
def _annotation(message: Message) -> dict[str, Any] | None:
|
||||
annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
|
||||
return cast("dict[str, Any]", annotation) if isinstance(annotation, dict) else None
|
||||
|
||||
|
||||
def _token_count(message: Message) -> int | None:
|
||||
annotation = _annotation(message)
|
||||
return annotation.get(GROUP_TOKEN_COUNT_KEY) if annotation else None
|
||||
|
||||
|
||||
def _relation(message: Message) -> str:
|
||||
"""Describe how a projected message relates to the original messages."""
|
||||
annotation = _annotation(message)
|
||||
if annotation is None:
|
||||
return ""
|
||||
summarizes = annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)
|
||||
if summarizes:
|
||||
return f" <- summary of {summarizes}"
|
||||
return ""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. Build synthetic history representing long-running in-run growth.
|
||||
# 1. Build synthetic history representing long-running, tool-using growth.
|
||||
messages = _build_long_history()
|
||||
|
||||
# 2. Configure tokenizer and measure token count before compaction.
|
||||
@@ -70,22 +130,35 @@ async def main() -> None:
|
||||
annotate_message_groups(messages, tokenizer=tokenizer)
|
||||
budget_before = included_token_count(messages)
|
||||
|
||||
# 3. Configure composed strategy stack.
|
||||
print("Before compaction message set:")
|
||||
for msg in messages:
|
||||
text_preview = msg.text[:80] if msg.text else "<non-text>"
|
||||
print(f"- [{msg.role}] {text_preview} ({msg.message_id}, {_token_count(msg)} tokens)")
|
||||
print()
|
||||
|
||||
# 3. Create a real summarizer client. SummarizationStrategy only requires a
|
||||
# SupportsChatGetResponse-compatible client.
|
||||
summarizer = OpenAIChatClient(model="gpt-4o-mini")
|
||||
|
||||
# 4. Configure the composed strategy stack. Strategies run in order and the
|
||||
# composed strategy stops as soon as the included-token budget is met.
|
||||
# The budget is set high enough that the generated summary fits within it:
|
||||
# a tighter budget would trip the composed fallback, which excludes the
|
||||
# oldest group first (the summary) once the included set exceeds the
|
||||
# budget. SlidingWindowStrategy remains as a recency safety net for longer
|
||||
# histories; for this sample summarization alone reaches budget, so the
|
||||
# window does not need to fire.
|
||||
composed = TokenBudgetComposedStrategy(
|
||||
token_budget=200,
|
||||
token_budget=400,
|
||||
tokenizer=tokenizer,
|
||||
strategies=[
|
||||
SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
|
||||
SummarizationStrategy(
|
||||
client=BudgetSummaryClient(),
|
||||
target_count=3,
|
||||
threshold=3,
|
||||
),
|
||||
SummarizationStrategy(client=summarizer, target_count=3, threshold=2),
|
||||
SlidingWindowStrategy(keep_last_groups=4),
|
||||
],
|
||||
)
|
||||
|
||||
# 4. Apply compaction and inspect the budget result.
|
||||
# 5. Apply compaction and inspect the budget result.
|
||||
projected = await apply_compaction(messages, strategy=composed, tokenizer=tokenizer)
|
||||
budget_after = included_token_count(messages)
|
||||
|
||||
@@ -95,23 +168,44 @@ async def main() -> None:
|
||||
print("Projected roles:", [m.role for m in projected])
|
||||
print("Projected messages with token counts:")
|
||||
for msg in projected:
|
||||
group = msg.additional_properties.get("_group")
|
||||
token_count = group.get("token_count") if isinstance(group, dict) else None
|
||||
text_preview = msg.text[:80] if msg.text else "<non-text>"
|
||||
print(f"- [{msg.role}] {text_preview} ({token_count} tokens)")
|
||||
print(f"- [{msg.role}] {text_preview} ({msg.message_id}, {_token_count(msg)} tokens){_relation(msg)}")
|
||||
|
||||
# 6. Surface the model-generated summary, if summarization fired.
|
||||
for msg in messages:
|
||||
annotation = _annotation(msg)
|
||||
if annotation and annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY):
|
||||
print("\nGenerated summary:")
|
||||
print(f" {msg.text}")
|
||||
print(f" summarizes: {annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
Projected messages after compaction: 3
|
||||
Included token count before compaction: 793
|
||||
Included token count after compaction: 144
|
||||
Projected roles: ['system', 'user', 'assistant']
|
||||
Sample output (summary text and token counts vary because the summary is generated by the model):
|
||||
|
||||
Before compaction message set:
|
||||
- [system] You are a migration copilot that plans and executes database migrations. (msg_0, 46 tokens)
|
||||
- [user] Iteration 1: capture migration requirements, constraints, and edge cases in deta (msg_1, 48 tokens)
|
||||
- [assistant] Iteration 1: produced a detailed plan covering dependencies, rollback guidance, (msg_2, 73 tokens)
|
||||
...
|
||||
- [user] What is the safest order to migrate these tables? (msg_12, 40 tokens)
|
||||
- [assistant] Migrate reference tables (users) first, then orders, then invoices, and events l (msg_13, 50 tokens)
|
||||
|
||||
Projected messages after compaction: 5
|
||||
Included token count before compaction: 757
|
||||
Included token count after compaction: 274
|
||||
Projected roles: ['system', 'assistant', 'assistant', 'user', 'assistant']
|
||||
Projected messages with token counts:
|
||||
- [system] You are a migration copilot. (35 tokens)
|
||||
- [user] Iteration 7: capture migration requirements and edge cases. (43 tokens)
|
||||
- [assistant] Iteration 7: detailed plan with dependencies, rollback guidance, and testing det (66 tokens)
|
||||
- [system] You are a migration copilot that plans and executes database migrations. (msg_0, 46 tokens)
|
||||
- [assistant] Across four planning turns the user and assistant... (summary_14, 96 tokens) <- summary of [msg_1..8]
|
||||
- [assistant] Schema inspection found four core tables to migrate. (msg_11, 42 tokens)
|
||||
- [user] What is the safest order to migrate these tables? (msg_12, 40 tokens)
|
||||
- [assistant] Migrate reference tables (users) first, then orders, then invoices, and events l (msg_13, 50 tokens)
|
||||
|
||||
Generated summary:
|
||||
Across four planning turns the user and assistant defined the migration requirements...
|
||||
summarizes: ['msg_1', 'msg_2', 'msg_3', 'msg_4', 'msg_5', 'msg_6', 'msg_7', 'msg_8']
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
GROUP_ANNOTATION_KEY,
|
||||
SUMMARIZED_BY_SUMMARY_ID_KEY,
|
||||
SUMMARY_OF_MESSAGE_IDS_KEY,
|
||||
Message,
|
||||
SummarizationStrategy,
|
||||
apply_compaction,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""This sample demonstrates the SummarizationStrategy directly.
|
||||
|
||||
Unlike SlidingWindow/Truncation strategies that simply drop older groups,
|
||||
``SummarizationStrategy`` calls a real chat client to *summarize* the oldest
|
||||
message groups, replaces them with a single linked summary message, and keeps
|
||||
the most recent turns verbatim. This preserves long-range context (decisions,
|
||||
goals, unresolved items) while bounding the prompt size.
|
||||
|
||||
Key components:
|
||||
- SummarizationStrategy with a real OpenAIChatClient summarizer
|
||||
- ``apply_compaction`` to run the strategy over a message list
|
||||
- Bidirectional summary trace metadata (summary -> originals, original -> summary)
|
||||
|
||||
Run with:
|
||||
uv run samples/02-agents/compaction/summarization.py # requires OPENAI_API_KEY
|
||||
"""
|
||||
|
||||
|
||||
def _annotation(message: Message) -> dict[str, Any] | None:
|
||||
annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
|
||||
return cast("dict[str, Any]", annotation) if isinstance(annotation, dict) else None
|
||||
|
||||
|
||||
def _build_history() -> list[Message]:
|
||||
"""Build a multi-turn conversation long enough to trigger summarization."""
|
||||
return [
|
||||
Message(role="system", contents=["You are a project planning assistant."]),
|
||||
Message(role="user", contents=["We are migrating a monolith to microservices. Where do we start?"]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=["Start by mapping bounded contexts and identifying the highest-churn modules to extract first."],
|
||||
),
|
||||
Message(role="user", contents=["The billing module changes most often. What are the risks of extracting it?"]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=["Main risks: distributed transactions, invoices-table ownership, and latency on hot paths."],
|
||||
),
|
||||
Message(role="user", contents=["How should we handle the shared invoices table?"]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=["Use the strangler-fig pattern: dual-write during transition, then make billing the owner."],
|
||||
),
|
||||
Message(role="user", contents=["What is the most recent decision we made?"]),
|
||||
Message(role="assistant", contents=["We decided to extract billing first using the strangler-fig pattern."]),
|
||||
]
|
||||
|
||||
|
||||
def _print_messages(label: str, messages: list[Message]) -> None:
|
||||
print(f"\n--- {label} ---")
|
||||
print(f"Message count: {len(messages)}")
|
||||
for index, message in enumerate(messages, start=1):
|
||||
text = message.text or ", ".join(content.type for content in message.contents)
|
||||
print(f"{index:02d}. [{message.role}] {text[:90]}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 1. Create a real summarizing client. SummarizationStrategy only requires a
|
||||
# SupportsChatGetResponse-compatible client, so any chat client works.
|
||||
summarizer = OpenAIChatClient(model="gpt-4o-mini")
|
||||
|
||||
# 2. Build a conversation and show it before compaction.
|
||||
messages = _build_history()
|
||||
_print_messages("Before compaction", messages)
|
||||
|
||||
# 3. Configure the strategy. It triggers once the included non-system message
|
||||
# count exceeds ``target_count + threshold`` (here 4 + 2 = 6), summarizing
|
||||
# the oldest groups down toward ``target_count`` while keeping recent turns.
|
||||
strategy = SummarizationStrategy(
|
||||
client=summarizer,
|
||||
target_count=4,
|
||||
threshold=2,
|
||||
)
|
||||
|
||||
# 4. Apply the strategy. The oldest groups are summarized into a single
|
||||
# assistant message; the projected list is what the model would receive.
|
||||
projected = await apply_compaction(messages, strategy=strategy)
|
||||
_print_messages("After compaction (SummarizationStrategy)", projected)
|
||||
|
||||
# 5. Inspect the generated summary and its bidirectional trace metadata.
|
||||
print("\n--- Summary trace ---")
|
||||
for message in messages:
|
||||
annotation = _annotation(message)
|
||||
if annotation is None:
|
||||
continue
|
||||
summarizes = annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)
|
||||
if summarizes:
|
||||
print(f"Generated summary ({message.message_id}):")
|
||||
print(f" {message.text}")
|
||||
print(f" summarizes original ids: {summarizes}")
|
||||
summarized_by: dict[str | None, Any] = {}
|
||||
for message in messages:
|
||||
annotation = _annotation(message)
|
||||
if annotation is None:
|
||||
continue
|
||||
summary_id = annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY)
|
||||
if summary_id:
|
||||
summarized_by[message.message_id] = summary_id
|
||||
if summarized_by:
|
||||
print("Originals replaced by the summary:")
|
||||
for original_id, summary_id in summarized_by.items():
|
||||
print(f" {original_id} -> {summary_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output (summary text varies because it is generated by the model):
|
||||
|
||||
--- Before compaction ---
|
||||
Message count: 9
|
||||
01. [system] You are a project planning assistant.
|
||||
02. [user] We are migrating a monolith to microservices. Where do we start?
|
||||
03. [assistant] Start by mapping bounded contexts and identifying the highest-churn modules to ex
|
||||
04. [user] The billing module changes most often. What are the risks of extracting it?
|
||||
05. [assistant] Main risks: distributed transactions, data ownership of the invoices table, and lat
|
||||
06. [user] How should we handle the shared invoices table?
|
||||
07. [assistant] Use the strangler-fig pattern: dual-write during transition, then make billing the
|
||||
08. [user] What is the most recent decision we made?
|
||||
09. [assistant] We decided to extract billing first using the strangler-fig pattern.
|
||||
|
||||
--- After compaction (SummarizationStrategy) ---
|
||||
Message count: 6
|
||||
01. [system] You are a project planning assistant.
|
||||
02. [assistant] The user is migrating a monolith to microservices and decided to extract the billin
|
||||
03. [user] How should we handle the shared invoices table?
|
||||
04. [assistant] Use the strangler-fig pattern: dual-write during transition, then make billing the
|
||||
05. [user] What is the most recent decision we made?
|
||||
06. [assistant] We decided to extract billing first using the strangler-fig pattern.
|
||||
|
||||
--- Summary trace ---
|
||||
Generated summary (summary_9):
|
||||
The user is migrating a monolith to microservices and decided to extract the billing module first...
|
||||
summarizes original ids: ['msg_1', 'msg_2', 'msg_3', 'msg_4', 'msg_5']
|
||||
Originals replaced by the summary:
|
||||
msg_1 -> summary_9
|
||||
msg_2 -> summary_9
|
||||
msg_3 -> summary_9
|
||||
msg_4 -> summary_9
|
||||
msg_5 -> summary_9
|
||||
"""
|
||||
@@ -27,6 +27,7 @@ This folder contains Azure AI Foundry and Foundry Local samples for Agent Framew
|
||||
| [`foundry_chat_client_with_local_mcp.py`](foundry_chat_client_with_local_mcp.py) | Foundry Chat Client with local MCP |
|
||||
| [`foundry_chat_client_with_session.py`](foundry_chat_client_with_session.py) | Foundry Chat Client with session management |
|
||||
| [`foundry_chat_client_with_toolbox.py`](foundry_chat_client_with_toolbox.py) | Foundry Chat Client connected to a toolbox via its MCP endpoint using `MCPStreamableHTTPTool` |
|
||||
| [`foundry_chat_client_with_toolbox_skills.py`](foundry_chat_client_with_toolbox_skills.py) | Foundry Chat Client that discovers MCP-based skills from a Foundry Toolbox endpoint via `MCPSkillsSource` (uses an Azure AD bearer token and the toolbox preview header) |
|
||||
|
||||
## FoundryLocalClient Samples
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
|
||||
import httpx
|
||||
from agent_framework import Agent, MCPSkillsSource, SkillsProvider
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.identity import AzureCliCredential, get_bearer_token_provider
|
||||
from dotenv import load_dotenv
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Foundry Chat Client with Toolbox-Hosted Skills
|
||||
|
||||
Discover Agent Skills served by a Microsoft Foundry Toolbox MCP endpoint
|
||||
and inject them into a ``FoundryChatClient`` agent via ``MCPSkillsSource``.
|
||||
The toolbox's discovery document (``skill://index.json``) is read once at
|
||||
startup; SKILL.md bodies are fetched on demand as the agent uses them.
|
||||
|
||||
Prerequisites:
|
||||
- A Microsoft Foundry project with a toolbox that exposes
|
||||
``skill://index.json`` with ``skill-md`` entries
|
||||
- FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL environment variables set
|
||||
- FOUNDRY_TOOLBOX_MCP_SERVER_URL: the toolbox's MCP endpoint URL, e.g.
|
||||
``https://<account>.services.ai.azure.com/api/projects/<project>/toolboxes/<name>/mcp?api-version=v1``
|
||||
- Azure CLI authentication (``az login``)
|
||||
"""
|
||||
|
||||
|
||||
class _BearerAuth(httpx.Auth):
|
||||
"""Attach a fresh Foundry bearer token to every request."""
|
||||
|
||||
def __init__(self, credential: TokenCredential) -> None:
|
||||
self._get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
request.headers["Authorization"] = f"Bearer {self._get_token()}"
|
||||
yield request
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Example showing toolbox-hosted MCP skills for a Foundry Chat Client agent."""
|
||||
credential = AzureCliCredential()
|
||||
|
||||
# HTTP client that signs every request with a fresh Foundry bearer token
|
||||
# and advertises the toolbox preview feature flag, plus the MCP streamable
|
||||
# HTTP transport that uses it.
|
||||
async with (
|
||||
httpx.AsyncClient(
|
||||
auth=_BearerAuth(credential),
|
||||
headers={"Foundry-Features": "Toolboxes=V1Preview"},
|
||||
timeout=httpx.Timeout(30.0, read=300.0),
|
||||
follow_redirects=True,
|
||||
) as http_client,
|
||||
streamable_http_client(
|
||||
url=os.environ["FOUNDRY_TOOLBOX_MCP_SERVER_URL"],
|
||||
http_client=http_client,
|
||||
) as (read, write, _),
|
||||
ClientSession(read, write) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
|
||||
# Discover skills served by the toolbox and inject them as a context provider.
|
||||
skills_provider = SkillsProvider(MCPSkillsSource(client=session))
|
||||
|
||||
async with Agent(
|
||||
client=FoundryChatClient(credential=credential),
|
||||
name="ToolboxMCPSkillsAgent",
|
||||
instructions="You are a helpful assistant. Use available skills to answer the user.",
|
||||
context_providers=[skills_provider],
|
||||
) as agent:
|
||||
query = input("User: ").strip() # noqa: ASYNC250
|
||||
if not query:
|
||||
return
|
||||
response = await agent.run(query)
|
||||
print(f"Assistant: {response.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -23,7 +23,7 @@ The following environment variables can be configured:
|
||||
| `GITHUB_COPILOT_MODEL` | Model to use (e.g., "gpt-5", "claude-sonnet-4") | Server default |
|
||||
| `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` |
|
||||
| `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` |
|
||||
| `GITHUB_COPILOT_COPILOT_HOME` | Directory for CLI session state and config | `~/.copilot` |
|
||||
| `GITHUB_COPILOT_BASE_DIRECTORY` | Directory for CLI session state and config | `~/.copilot` |
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ from typing import Annotated
|
||||
|
||||
from agent_framework import tool
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.session import PermissionHandler
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
@@ -28,19 +27,6 @@ from pydantic import Field
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if request.full_command_text is not None:
|
||||
print(f" Command: {request.full_command_text}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@@ -60,7 +46,7 @@ async def non_streaming_example() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
@@ -77,7 +63,7 @@ async def streaming_example() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
@@ -97,7 +83,7 @@ async def runtime_options_example() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="Always respond in exactly 3 words.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
|
||||
+8
-12
@@ -4,8 +4,7 @@
|
||||
GitHub Copilot Agent with File Operation Permissions
|
||||
|
||||
This sample demonstrates how to enable file read and write operations with GitHubCopilotAgent.
|
||||
By providing a permission handler that approves "read" and/or "write" requests, the agent can
|
||||
read from and write to files on the filesystem.
|
||||
By providing a permission handler, the agent can read from and write to files on the filesystem.
|
||||
|
||||
SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
|
||||
- "read" allows the agent to read any accessible file
|
||||
@@ -15,21 +14,18 @@ SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
|
||||
from copilot.session import PermissionHandler, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
async def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if request.path is not None:
|
||||
print(f" Path: {request.path}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
response = (await asyncio.to_thread(input, "Approve? (y/n): ")).strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
return PermissionHandler.approve_all(request, context)
|
||||
return PermissionDecisionDeniedInteractivelyByUser()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
||||
+31
-20
@@ -32,6 +32,7 @@ from typing import Annotated
|
||||
|
||||
from agent_framework import Content, tool
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.session import PermissionHandler
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
@@ -48,37 +49,42 @@ def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Fr
|
||||
)
|
||||
|
||||
|
||||
def prompt_for_approval(call: Content) -> bool:
|
||||
"""Synchronous approval prompt.
|
||||
async def prompt_for_approval(call: Content) -> bool:
|
||||
"""Async approval callback that prompts the user interactively.
|
||||
|
||||
The callback receives a ``FunctionCallContent`` so the operator can review
|
||||
the tool name and arguments before deciding. Returning ``True`` allows the
|
||||
call; returning ``False`` denies it and a tool-error is returned to the
|
||||
model.
|
||||
|
||||
Uses ``asyncio.to_thread`` so the event loop is not blocked by ``input()``.
|
||||
"""
|
||||
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = input("Approve this tool call? (y/n): ").strip().lower()
|
||||
print(f"\n [Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = (await asyncio.to_thread(input, " Approve this tool call? (y/n): ")).strip().lower()
|
||||
return response in ("y", "yes")
|
||||
|
||||
|
||||
async def prompt_for_approval_async(call: Content) -> bool:
|
||||
"""Async approval prompt.
|
||||
def auto_approve(call: Content) -> bool:
|
||||
"""Synchronous approval callback that always approves.
|
||||
|
||||
Use an async callback when approval requires I/O (e.g. an HTTP call to a
|
||||
review service or queueing the request to a UI). ``input()`` is wrapped
|
||||
with ``asyncio.to_thread`` so the event loop is not blocked.
|
||||
Use a sync callback for simple, non-blocking decisions that don't require
|
||||
I/O (e.g. checking an allow-list of tool names).
|
||||
"""
|
||||
print(f"\n[Function Approval Request - async]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = await asyncio.to_thread(input, "Approve this tool call? (y/n): ")
|
||||
return response.strip().lower() in ("y", "yes")
|
||||
print(f"\n [Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
print(" -> Auto-approved")
|
||||
return True
|
||||
|
||||
|
||||
async def run_with_sync_callback() -> None:
|
||||
print("\n=== GitHub Copilot Agent: synchronous approval callback ===")
|
||||
async def run_with_interactive_callback() -> None:
|
||||
"""Demonstrates an interactive approval prompt before tool execution."""
|
||||
print("\n=== GitHub Copilot Agent: interactive approval callback ===")
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_function_approval": prompt_for_approval},
|
||||
default_options={
|
||||
"on_function_approval": prompt_for_approval,
|
||||
"on_permission_request": PermissionHandler.approve_all,
|
||||
},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Seattle."
|
||||
@@ -87,12 +93,16 @@ async def run_with_sync_callback() -> None:
|
||||
print(f"Agent: {result}")
|
||||
|
||||
|
||||
async def run_with_async_callback() -> None:
|
||||
print("\n=== GitHub Copilot Agent: asynchronous approval callback ===")
|
||||
async def run_with_auto_approve_callback() -> None:
|
||||
"""Demonstrates a synchronous callback that always approves."""
|
||||
print("\n=== GitHub Copilot Agent: synchronous auto-approve callback ===")
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_function_approval": prompt_for_approval_async},
|
||||
default_options={
|
||||
"on_function_approval": auto_approve,
|
||||
"on_permission_request": PermissionHandler.approve_all,
|
||||
},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Tokyo."
|
||||
@@ -112,6 +122,7 @@ async def run_without_callback() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Paris."
|
||||
@@ -122,8 +133,8 @@ async def run_without_callback() -> None:
|
||||
|
||||
async def main() -> None:
|
||||
print("=== GitHub Copilot Agent: Function approval enforcement ===")
|
||||
await run_with_sync_callback()
|
||||
await run_with_async_callback()
|
||||
await run_with_interactive_callback()
|
||||
await run_with_auto_approve_callback()
|
||||
await run_without_callback()
|
||||
|
||||
|
||||
|
||||
+3
-14
@@ -22,24 +22,13 @@ import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.session import PermissionHandler
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
|
||||
|
||||
async def default_instructions_example() -> None:
|
||||
"""Example of pointing the agent at project-specific instruction directories."""
|
||||
print("=== Instruction Directories (Default) ===\n")
|
||||
@@ -58,7 +47,7 @@ async def default_instructions_example() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
default_options={
|
||||
"on_permission_request": prompt_permission,
|
||||
"on_permission_request": PermissionHandler.approve_all,
|
||||
"instruction_directories": instruction_dirs,
|
||||
},
|
||||
)
|
||||
@@ -79,7 +68,7 @@ async def runtime_override_example() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant.",
|
||||
default_options={
|
||||
"on_permission_request": prompt_permission,
|
||||
"on_permission_request": PermissionHandler.approve_all,
|
||||
"instruction_directories": ["/team/shared/instructions"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -15,24 +15,13 @@ of MCP-related actions.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import MCPServerConfig, PermissionRequestResult
|
||||
from copilot.session import MCPServerConfig, PermissionHandler
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== GitHub Copilot Agent with MCP Servers ===\n")
|
||||
|
||||
@@ -56,7 +45,7 @@ async def main() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant with access to the local filesystem and Microsoft Learn.",
|
||||
default_options={
|
||||
"on_permission_request": prompt_permission,
|
||||
"on_permission_request": PermissionHandler.approve_all,
|
||||
"mcp_servers": mcp_servers,
|
||||
},
|
||||
)
|
||||
|
||||
+11
-21
@@ -3,9 +3,8 @@
|
||||
"""
|
||||
GitHub Copilot Agent with Multiple Permissions
|
||||
|
||||
This sample demonstrates how to enable multiple permission types with GitHubCopilotAgent.
|
||||
By combining different permission kinds in the handler, the agent can perform complex tasks
|
||||
that require multiple capabilities.
|
||||
This sample demonstrates how multiple permission types are requested when GitHubCopilotAgent
|
||||
performs complex tasks that require different capabilities.
|
||||
|
||||
Available permission kinds:
|
||||
- "shell": Execute shell commands
|
||||
@@ -21,23 +20,14 @@ More permissions mean more potential for unintended actions.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.session import PermissionHandler, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if request.full_command_text is not None:
|
||||
print(f" Command: {request.full_command_text}")
|
||||
if request.path is not None:
|
||||
print(f" Path: {request.path}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that auto-approves and logs each permission kind."""
|
||||
print(f" [Permission: {request.kind}]", flush=True)
|
||||
return PermissionHandler.approve_all(request, context)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -45,14 +35,14 @@ async def main() -> None:
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful development assistant that can read, write files and run commands.",
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": approve_and_log},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
query = "List the first 3 Python files, then read the first one and create a summary in summary.txt"
|
||||
print(f"User: {query}")
|
||||
print(f"User: {query}\n")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}\n")
|
||||
print(f"\nAgent: {result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -14,24 +14,10 @@ from typing import Annotated
|
||||
|
||||
from agent_framework import tool
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.session import PermissionHandler
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if request.full_command_text is not None:
|
||||
print(f" Command: {request.full_command_text}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@@ -51,7 +37,7 @@ async def example_with_automatic_session_creation() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
@@ -76,7 +62,7 @@ async def example_with_session_persistence() -> None:
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
@@ -113,7 +99,7 @@ async def example_with_existing_session_id() -> None:
|
||||
agent1 = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent1:
|
||||
@@ -135,7 +121,7 @@ async def example_with_existing_session_id() -> None:
|
||||
agent2 = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent2:
|
||||
|
||||
@@ -14,21 +14,20 @@ Shell commands have full access to your system within the permissions of the run
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
from copilot.session import PermissionHandler, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if request.full_command_text is not None:
|
||||
print(f" Command: {request.full_command_text}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that approves only shell commands and logs them."""
|
||||
if request.kind == "shell":
|
||||
print(f"\n [Permission: {request.kind}]", flush=True)
|
||||
command = getattr(request, "full_command_text", None)
|
||||
if command is not None:
|
||||
print(f" Command: {command}", flush=True)
|
||||
return PermissionHandler.approve_all(request, context)
|
||||
return PermissionDecisionUserNotAvailable()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -36,14 +35,14 @@ async def main() -> None:
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant that can execute shell commands.",
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": approve_and_log},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
query = "List the first 3 Python files in the current directory"
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}\n")
|
||||
print(f"\nAgent: {result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -14,21 +14,20 @@ URL fetching allows the agent to access any URL accessible from your network.
|
||||
import asyncio
|
||||
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
from copilot.session import PermissionHandler, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
|
||||
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that prompts the user for approval."""
|
||||
print(f"\n[Permission Request: {request.kind}]")
|
||||
|
||||
if request.url is not None:
|
||||
print(f" URL: {request.url}")
|
||||
|
||||
response = input("Approve? (y/n): ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
"""Permission handler that approves only URL requests and logs them."""
|
||||
if request.kind == "url":
|
||||
print(f"\n [Permission: {request.kind}]", flush=True)
|
||||
url = getattr(request, "url", None)
|
||||
if url is not None:
|
||||
print(f" URL: {url}", flush=True)
|
||||
return PermissionHandler.approve_all(request, context)
|
||||
return PermissionDecisionUserNotAvailable()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -36,14 +35,14 @@ async def main() -> None:
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant that can fetch and summarize web content.",
|
||||
default_options={"on_permission_request": prompt_permission},
|
||||
default_options={"on_permission_request": approve_and_log},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
query = "Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents"
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}\n")
|
||||
print(f"\nAgent: {result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,6 +12,7 @@ Start with file-based or code-defined skills, then explore combining them and ad
|
||||
| [**code_defined_skill**](code_defined_skill/) | Define skills entirely in Python code using `Skill`, `@skill.resource`, and `@skill.script` decorators. Uses a code-defined unit-converter skill. |
|
||||
| [**class_based_skill**](class_based_skill/) | Define skills as Python classes using `ClassSkill` with `@ClassSkill.resource` and `@ClassSkill.script` decorators for auto-discovery. Uses a class-based unit-converter skill. |
|
||||
| [**mixed_skills**](mixed_skills/) | Combine code-defined, class-based, and file-based skills in a single agent. Uses a code-defined volume-converter, a class-based temperature-converter, and a file-based unit-converter. |
|
||||
| [**mcp_based_skill**](mcp_based_skill/) | Discover skills served over the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) via `MCPSkillsSource`. Connects to a remote MCP server that exposes skills as `skill://...` resources following the SEP-2640 convention. |
|
||||
| [**script_approval**](script_approval/) | Require human-in-the-loop approval before executing skill scripts |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# MCP-Based Agent Skills Sample
|
||||
|
||||
This sample demonstrates how to discover **Agent Skills served over MCP** with an `Agent`.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Connecting to a remote MCP server (over streamable HTTP) that exposes skill
|
||||
resources following the SEP-2640 convention.
|
||||
- Building a `SkillsProvider` from an `MCPSkillsSource`, which reads
|
||||
`skill://index.json` (SEP-2640 canonical discovery) and constructs skills from
|
||||
the index entries.
|
||||
- The progressive disclosure pattern across MCP: advertise → load → read
|
||||
resources, exactly as for filesystem-backed skills.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- An [Azure AI Foundry](https://ai.azure.com/) project with a deployed model
|
||||
- Azure CLI authentication (`az login`)
|
||||
- A running MCP server that hosts SEP-2640 skill resources (see "Providing
|
||||
an MCP server" below)
|
||||
|
||||
### Setup
|
||||
|
||||
Set the following environment variables (in a `.env` file or your shell):
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-endpoint.services.ai.azure.com/api/projects/your-project"
|
||||
$env:FOUNDRY_MODEL="gpt-4o-mini"
|
||||
$env:MCP_SKILLS_SERVER_URL="https://your-mcp-server.example.com/mcp"
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```powershell
|
||||
python mcp_based_skill.py
|
||||
```
|
||||
|
||||
## Providing an MCP server
|
||||
|
||||
This sample is a **consumer**: it does not host an MCP server itself. To try
|
||||
it end-to-end you need an MCP server that exposes the SEP-2640 skill
|
||||
resources (`skill://index.json` plus per-skill `SKILL.md`).
|
||||
|
||||
- See [`samples/02-agents/mcp/agent_as_mcp_server.py`](../../mcp/agent_as_mcp_server.py)
|
||||
for an example of hosting an MCP server via the Agent Framework.
|
||||
- The Model Context Protocol working group maintains reference MCP-skills
|
||||
servers at
|
||||
[`modelcontextprotocol/experimental-ext-skills`](https://github.com/modelcontextprotocol/experimental-ext-skills).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Uncomment this filter to suppress the experimental Skills warning before
|
||||
# using the sample's Skills APIs.
|
||||
# import warnings
|
||||
# warnings.filterwarnings("ignore", message=r"\[SKILLS\].*", category=FutureWarning)
|
||||
from agent_framework import Agent, MCPSkillsSource, SkillsProvider
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
"""
|
||||
MCP-Based Agent Skills
|
||||
|
||||
This sample demonstrates how to discover Agent Skills served over the
|
||||
Model Context Protocol (MCP) using :class:`MCPSkillsSource`.
|
||||
|
||||
The sample connects to a remote MCP server that exposes skill resources
|
||||
under the ``skill://`` URI scheme:
|
||||
|
||||
* ``skill://index.json`` — discovery document listing all skills
|
||||
* ``skill://<skill-name>/SKILL.md`` — the skill instructions
|
||||
|
||||
To run, set ``MCP_SKILLS_SERVER_URL`` to the streamable HTTP endpoint of an
|
||||
MCP server that hosts the skill resources.
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Connect to a remote MCP skills server and run the agent."""
|
||||
load_dotenv()
|
||||
|
||||
endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
|
||||
deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o-mini")
|
||||
mcp_url = os.environ["MCP_SKILLS_SERVER_URL"]
|
||||
|
||||
print("Discovering MCP-based skills")
|
||||
print("-" * 60)
|
||||
|
||||
# 1. Connect to the MCP server over streamable HTTP.
|
||||
async with streamable_http_client(url=mcp_url) as (read, write, _), ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# 2. Build a SkillsProvider that discovers skills over MCP.
|
||||
# MCPSkillsSource reads skill://index.json and creates one
|
||||
# MCPSkill per skill-md entry; SKILL.md bodies are fetched
|
||||
# on demand via resources/read.
|
||||
skills_provider = SkillsProvider(MCPSkillsSource(client=session))
|
||||
|
||||
# 3. Run the agent.
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=endpoint,
|
||||
model=deployment,
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
async with Agent(
|
||||
client=client,
|
||||
instructions="You are a helpful assistant. Use available skills to answer the user.",
|
||||
context_providers=[skills_provider],
|
||||
) as agent:
|
||||
query = input("User: ").strip() # noqa: ASYNC250
|
||||
if not query:
|
||||
return
|
||||
response = await agent.run(query)
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user