Compare commits

..
Author SHA1 Message Date
462f37e77d Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 18:19:34 -07:00
Azure SRE Agent 70c88d2150 docs: clarify checkpoint storage security model and deserialization trust boundaries
Add Security Model documentation sections to the checkpoint encoding and
Azure Functions serialization modules explaining:
- Checkpoint storage is a trusted data source requiring access controls
- The RestrictedUnpickler allowlist is defense-in-depth, not a security boundary
- Developer responsibilities for securing storage backends
- Guidance on using allowed_types and strip_pickle_markers

Co-authored-by: Azure SRE Agent <noreply@microsoft.com>
2026-06-03 01:01:00 +00:00
109 changed files with 631 additions and 4655 deletions
+1 -16
View File
@@ -8,7 +8,6 @@ function getPullRequest(context) {
return {
author: pullRequest.user.login,
authorType: pullRequest.user.type,
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
number: pullRequest.number,
};
@@ -50,10 +49,6 @@ 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}.`,
@@ -88,17 +83,7 @@ async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
const { owner, repo } = context.repo;
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,
};
}
const { author, labels, number } = getPullRequest(context);
if (hasLabel(labels, exemptLabelName)) {
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
+1 -26
View File
@@ -16,7 +16,7 @@ const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
// Helpers
// ---------------------------------------------------------------------------
function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
function createContext({ author = 'community-user', labels = [], number = 123 } = {}) {
return {
repo: {
owner: 'microsoft',
@@ -28,7 +28,6 @@ function createContext({ author = 'community-user', authorType = 'User', labels
labels: labels.map((name) => ({ name })),
user: {
login: author,
type: authorType,
},
},
},
@@ -297,30 +296,6 @@ 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)],
+1 -42
View File
@@ -474,45 +474,6 @@ 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
@@ -529,7 +490,6 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
runs-on: ubuntu-latest
defaults:
@@ -593,8 +553,7 @@ jobs:
python-tests-functions,
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot
python-tests-cosmos
]
steps:
- name: Fail workflow if tests failed
-57
View File
@@ -40,7 +40,6 @@ 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
@@ -86,8 +85,6 @@ 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'
@@ -661,58 +658,6 @@ 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
@@ -729,7 +674,6 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
runs-on: ubuntu-latest
defaults:
@@ -791,7 +735,6 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
steps:
- name: Fail workflow if tests failed
+1 -1
View File
@@ -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.2.0" />
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.9.0</VersionPrefix>
<VersionPrefix>1.8.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260603</DateSuffix>
<DateSuffix>260528</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.9.0</GitTag>
<GitTag>1.8.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -10,11 +10,6 @@ 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,7 +14,6 @@
<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,11 +16,6 @@ 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,7 +14,6 @@
<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,11 +10,6 @@ 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,7 +14,6 @@
<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,11 +27,6 @@ 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,7 +14,6 @@
<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,11 +17,6 @@ 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,7 +14,6 @@
<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,16 +50,12 @@ internal static partial class WorkflowHelper
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
[SendsMessage(typeof(List<ChatMessage>))]
[SendsMessage(typeof(TurnToken))]
private sealed partial class ConcurrentStartExecutor()
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
{
[MessageHandler]
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
}
[MessageHandler]
@@ -67,16 +63,13 @@ 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(string))]
private sealed partial class ConcurrentAggregationExecutor() :
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
[YieldsOutput(typeof(List<ChatMessage>))]
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
{
private readonly List<ChatMessage> _messages = [];
@@ -97,11 +90,5 @@ internal static partial class WorkflowHelper
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
public ValueTask ResetAsync()
{
this._messages.Clear();
return default;
}
}
}
@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -15,7 +15,6 @@
<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,11 +19,6 @@ 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,9 +49,8 @@ var agent = new AzureOpenAIClient(
AGUIServerSerializerContext.Default.Options)
]);
// 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.:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// Register the agent with the host and configure it to use an in-memory session store
@@ -14,7 +14,6 @@
<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,11 +12,6 @@ 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,9 +1,7 @@
// 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;
@@ -34,19 +32,11 @@ 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,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null) =>
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
HarnessAgentOptions? options = null) =>
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
}
@@ -10,7 +10,6 @@ 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;
@@ -106,12 +105,6 @@ 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>
@@ -119,20 +112,18 @@ 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, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
: base(BuildAgent(
Throw.IfNull(chatClient),
maxContextWindowTokens,
maxOutputTokens,
options,
loggerFactory,
services))
options))
{
}
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
AIAgentBuilder builder = innerAgent.AsBuilder();
@@ -146,10 +137,10 @@ public sealed class HarnessAgent : DelegatingAIAgent
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
}
return builder.Build(services);
return builder.Build();
}
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
{
var compactionStrategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: maxContextWindowTokens,
@@ -174,13 +165,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
var compactionProvider = new CompactionProvider(compactionStrategy);
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
return chatClient
.AsBuilder()
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
.UseMessageInjection()
@@ -198,9 +189,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
RequirePerServiceCallChatHistoryPersistence = true,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
},
loggerFactory,
services);
});
}
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
@@ -226,7 +215,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
return result;
}
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options, ILoggerFactory? loggerFactory)
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
{
var providers = new List<AIContextProvider>();
@@ -266,8 +255,8 @@ public sealed class HarnessAgent : DelegatingAIAgent
if (options?.DisableAgentSkillsProvider is not true)
{
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
? new AgentSkillsProvider(source, loggerFactory: loggerFactory)
: new AgentSkillsProvider(Directory.GetCurrentDirectory(), loggerFactory: loggerFactory);
? new AgentSkillsProvider(source)
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
providers.Add(skillsProvider);
}
@@ -103,16 +103,7 @@ public static class AGUIEndpointRouteBuilderExtensions
ArgumentNullException.ThrowIfNull(aiAgent);
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
// 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);
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
{
@@ -27,14 +27,6 @@ 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>
@@ -83,10 +75,6 @@ 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.
@@ -149,14 +137,13 @@ internal sealed class InvokeMcpToolExecutor(
return;
}
// 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();
// Approved - now invoke the tool
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this._approvalSnapshot?.ConnectionName ?? this.GetConnectionName();
string? connectionName = this.GetConnectionName();
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
@@ -175,33 +162,9 @@ 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();
@@ -402,24 +365,4 @@ 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,13 +49,14 @@ public sealed class AgentFileSkill : AgentSkill
/// <inheritdoc/>
/// <remarks>
/// Returns the raw SKILL.md content. When the skill has scripts, a
/// <c>&lt;script_schemas&gt;</c> block is appended describing the argument format.
/// <c>&lt;scripts&gt;&lt;script name="..."&gt;&lt;parameters_schema&gt;...&lt;/parameters_schema&gt;&lt;/script&gt;&lt;/scripts&gt;</c>
/// block is appended with a per-script entry describing the expected 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.BuildScriptSchemasBlock(this._scripts)
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
: this._originalContent;
return new(content);
}
@@ -114,6 +114,7 @@ public abstract class AgentClassSkill<
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts));
}
@@ -146,17 +147,11 @@ 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;
@@ -164,17 +159,11 @@ 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>&lt;script_schemas&gt;</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;
@@ -195,10 +184,6 @@ 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>
@@ -209,10 +194,6 @@ 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>
@@ -227,10 +208,6 @@ 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>&lt;script_schemas&gt;</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._scripts));
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
}
/// <inheritdoc/>
@@ -115,10 +115,6 @@ 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>
@@ -133,10 +129,6 @@ 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>
@@ -155,10 +147,6 @@ 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>&lt;script_schemas&gt;</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>
@@ -12,17 +12,19 @@ namespace Microsoft.Agents.AI;
internal static class AgentInlineSkillContentBuilder
{
/// <summary>
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
/// Builds the complete skill content containing name, description, instructions, resources, and scripts.
/// </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);
@@ -37,24 +39,41 @@ 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(BuildScriptSchemasBlock(scripts));
sb.Append(BuildScriptsBlock(scripts));
}
return sb.ToString();
}
/// <summary>
/// Builds a <c>&lt;script_schemas&gt;...&lt;/script_schemas&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;schema script="..."&gt;</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.
/// Builds a <c>&lt;scripts&gt;...&lt;/scripts&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;script name="..."&gt;</c> element with optional
/// <c>description</c> attribute and <c>&lt;parameters_schema&gt;</c> child element.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <returns>An XML string starting with <c>\n&lt;script_schemas&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
/// <returns>An XML string starting with <c>\n&lt;scripts&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
{
_ = Throw.IfNull(scripts);
@@ -64,23 +83,32 @@ internal static class AgentInlineSkillContentBuilder
}
var sb = new StringBuilder();
sb.Append("\n<script_schemas>\n");
sb.Append("\n<scripts>\n");
foreach (var script in scripts)
{
var parametersSchema = script.ParametersSchema;
if (parametersSchema is null)
if (script.Description is null && parametersSchema is null)
{
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
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("</script_schemas>");
sb.Append("</scripts>");
return sb.ToString();
}
@@ -9,7 +9,6 @@ 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;
@@ -1461,131 +1460,4 @@ 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,8 +51,9 @@ 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 (with preserved quotes)
Assert.Contains("\"value\"", 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());
}
[Fact]
@@ -382,9 +383,10 @@ public sealed class AgentClassSkillTests
// Arrange
var skill = new AttributedFullSkill();
// 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());
// 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());
Assert.Contains("convert", await skill.GetContentAsync());
// Act & Assert — discovered members are cached
@@ -502,7 +504,7 @@ public sealed class AgentClassSkillTests
}
[Fact]
public async Task Content_DoesNotRenderResources_InBodyAsync()
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
@@ -510,8 +512,8 @@ public sealed class AgentClassSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in body content
Assert.DoesNotContain("<resources>", content);
// Assert — descriptions from [Description] attribute appear in synthesized content
Assert.Contains("Some important data.", content);
}
[Fact]
@@ -122,10 +122,11 @@ public sealed class AgentFileSkillScriptTests
// Assert — content starts with original and appends per-script entries
Assert.StartsWith("Original content", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("<schema script=\"build\">", content);
Assert.Contains("<schema script=\"deploy\">", content);
Assert.Contains("</script_schemas>", 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);
}
[Fact]
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -158,12 +158,13 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
Assert.DoesNotContain("<resources>", content);
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("config", content);
}
[Fact]
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -172,8 +173,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("dynamic", content);
}
[Fact]
@@ -187,7 +189,7 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<script_schemas>", content);
Assert.Contains("<scripts>", content);
Assert.Contains("run", content);
}
@@ -207,7 +209,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_IncludesScriptSchemasAddedBeforeFirstAccessAsync()
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -218,8 +220,9 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("<resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<scripts>", content);
Assert.Contains("s1", content);
}
@@ -233,9 +236,8 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// 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 — JSON schema should be present and XML content chars escaped
Assert.Contains("parameters_schema", content);
Assert.DoesNotContain("<![CDATA[", content);
}
@@ -427,7 +429,7 @@ public sealed class AgentInlineSkillTests
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("<script_schemas>", content);
Assert.DoesNotContain("<scripts>", content);
}
[Fact]
@@ -461,7 +463,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_ScriptWithDescription_DoesNotEmitDescriptionAttributeAsync()
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -470,10 +472,8 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// 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);
// Assert
Assert.Contains("description=\"Runs something.\"", content);
}
[Fact]
@@ -492,7 +492,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -502,10 +502,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("with-desc", content);
Assert.DoesNotContain("no-desc", content);
// Assert
Assert.Contains("description=\"A described resource.\"", content);
Assert.DoesNotContain("no-desc\" description", content);
}
[Fact]
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
@@ -12,9 +11,7 @@ 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;
@@ -845,313 +842,6 @@ 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]
@@ -1261,50 +951,6 @@ 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
+1 -39
View File
@@ -7,43 +7,6 @@ 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
@@ -1169,8 +1132,7 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...HEAD
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...HEAD
[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
+1 -1
View File
@@ -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` | `rc` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260604"
version = "1.0.0b260528"
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.8.0,<2",
"agent-framework-core>=1.7.0,<2",
"a2a-sdk>=1.0.0,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260604"
version = "1.0.0b260521"
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.8.0,<2",
"agent-framework-core>=1.6.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -14,6 +14,24 @@ This module adds:
- reconstruct_to_type: for HITL responses where external data (without type markers)
needs to be reconstructed to a known type
- resolve_type: resolves 'module:class' type keys to Python types
Security Model
--------------
The underlying Azure Durable Functions storage (Azure Storage account) is the
trusted persistence layer for serialized checkpoint data. The
``RestrictedUnpickler`` in the core encoding module provides defense-in-depth
type filtering, but checkpoint storage itself must be properly access-controlled:
- Ensure the Azure Storage account used by Durable Functions is not publicly
writable and uses appropriate RBAC / shared-access policies.
- Never route untrusted user input directly into ``deserialize_value`` without
first calling :func:`strip_pickle_markers` to neutralize injection of
pickle markers into the data path.
- Configure your checkpoint storage with ``allowed_checkpoint_types`` (or call
``decode_checkpoint_value(..., allowed_types=...)`` directly) to restrict the set of types that can be deserialized.
See :mod:`agent_framework._workflows._checkpoint_encoding` for the full
security model documentation.
"""
from __future__ import annotations
@@ -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.0b260604"
version = "1.0.0b260521"
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.8.0,<2",
"agent-framework-durabletask>=1.0.0b260604,<2",
"agent-framework-core>=1.6.0,<2",
"agent-framework-durabletask>=1.0.0b260521,<2",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
@@ -795,7 +795,10 @@ 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__
@@ -814,7 +817,9 @@ class BedrockChatClient(
return {
"textFormat": {
"type": "json_schema",
"structure": {"jsonSchema": json_schema},
"structure": {
"jsonSchema": json_schema
},
}
}
@@ -835,7 +840,9 @@ 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
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260604"
version = "1.0.0b260521"
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.8.0,<2",
"agent-framework-core>=1.6.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
@@ -238,7 +238,6 @@ 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
+1 -1
View File
@@ -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. 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).
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware
### Sessions (`_sessions.py`)
@@ -168,9 +168,6 @@ from ._skills import (
InlineSkillResource,
InlineSkillScript,
InMemorySkillsSource,
MCPSkill,
MCPSkillResource,
MCPSkillsSource,
Skill,
SkillFrontmatter,
SkillResource,
@@ -447,9 +444,6 @@ __all__ = [
"MCPStdioTool",
"MCPStreamableHTTPTool",
"MCPWebsocketTool",
"MCPSkill",
"MCPSkillResource",
"MCPSkillsSource",
"MemoryContextProvider",
"MemoryFileStore",
"MemoryIndexEntry",
@@ -380,15 +380,8 @@ 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(
working_messages,
prepared_messages,
strategy=compaction_strategy,
tokenizer=tokenizer,
)
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Mapping, Sequence
from typing import (
TYPE_CHECKING,
Any,
@@ -92,23 +92,10 @@ 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], *, 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)
def _ensure_message_ids(messages: list[Message]) -> None:
for index, message in enumerate(messages):
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)
if not message.message_id:
message.message_id = f"msg_{index}"
def _group_id_for(message: Message, group_index: int) -> str:
@@ -117,27 +104,14 @@ def _group_id_for(message: Message, group_index: int) -> str:
return f"group_index_{group_index}"
def group_messages(
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
) -> list[dict[str, Any]]:
def group_messages(messages: list[Message]) -> 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, id_offset=id_offset, reserved_ids=reserved_ids)
_ensure_message_ids(messages)
spans: list[dict[str, Any]] = []
i = 0
group_index = 0
@@ -465,8 +439,7 @@ def annotate_message_groups(
if previous_group_index is not None:
group_index_offset = previous_group_index + 1
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)
spans = group_messages(messages[start_index:])
for span_index, span in enumerate(spans):
group_id = str(span["group_id"])
kind = _coerce_group_kind(span["kind"])
@@ -58,8 +58,6 @@ 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,8 +349,6 @@ 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."""
@@ -473,8 +471,6 @@ 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,7 +11,6 @@ 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,
@@ -215,12 +214,6 @@ 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
@@ -239,18 +232,6 @@ 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__(
@@ -261,7 +242,6 @@ 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.
@@ -272,9 +252,6 @@ 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
@@ -282,96 +259,6 @@ 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,7 +44,6 @@ Only use skills from trusted sources.
from __future__ import annotations
import asyncio
import base64
import inspect
import json
import logging
@@ -61,10 +60,6 @@ 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
@@ -3290,443 +3285,4 @@ 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
+4 -38
View File
@@ -292,7 +292,6 @@ class FunctionTool(SerializationMixin):
"_cached_parameters",
"_input_schema",
"_schema_supplied",
"_invoke_sync_on_event_loop",
}
def __init__(
@@ -367,7 +366,6 @@ 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)
@@ -539,16 +537,6 @@ 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,
@@ -691,7 +679,8 @@ 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}")
result = await self._invoke_function(call_kwargs)
res = self.__call__(**call_kwargs)
result = await res if inspect.isawaitable(res) else res
if skip_parsing:
logger.info(f"Function {self.name} succeeded.")
logger.debug(f"Function result: {type(result).__name__}")
@@ -741,7 +730,8 @@ class FunctionTool(SerializationMixin):
start_time_stamp = perf_counter()
end_time_stamp: float | None = None
try:
result = await self._invoke_function(call_kwargs)
res = self.__call__(**call_kwargs)
result = await res if inspect.isawaitable(res) else res
end_time_stamp = perf_counter()
except Exception as exception:
end_time_stamp = perf_counter()
@@ -1428,7 +1418,6 @@ 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.
@@ -1443,8 +1432,6 @@ 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.
@@ -1536,7 +1523,6 @@ async def _auto_invoke_function(
arguments=args,
session=invocation_session,
kwargs=runtime_kwargs.copy(),
tools=live_tools,
)
function_result = await tool.invoke(
arguments=args,
@@ -1551,10 +1537,6 @@ 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}"
@@ -1570,7 +1552,6 @@ async def _auto_invoke_function(
arguments=args,
session=invocation_session,
kwargs=runtime_kwargs.copy(),
tools=live_tools,
)
call_id = function_call_content.call_id
@@ -1627,10 +1608,6 @@ 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}"
@@ -1682,9 +1659,6 @@ 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",
@@ -1759,7 +1733,6 @@ 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:
@@ -2398,13 +2371,6 @@ 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]:
@@ -13,6 +13,35 @@ during deserialization. The default built-in safe set covers common Python
value types (primitives, datetime, uuid, ...), all ``agent_framework`` internal
types, and all ``openai.types`` types. Callers can extend the set by passing
additional ``"module:qualname"`` strings.
Security Model
--------------
Checkpoint storage is treated as a **trusted data source**. The serialization
format uses Python's ``pickle`` module which can execute arbitrary code during
deserialization. The ``RestrictedUnpickler`` provides a defense-in-depth
allowlist that limits instantiable classes, but it is **not** a security
boundary — certain allowlisted builtins (e.g. ``getattr``) are required for
legitimate object reconstruction (enums, named tuples) and cannot be removed
without breaking compatibility.
Developers **must** ensure that:
1. The checkpoint storage backend (file system, Cosmos DB, Azure Blob, Durable
Functions storage) is access-controlled and not writable by untrusted
parties.
2. Data flowing into ``decode_checkpoint_value`` originates exclusively from
the application's own checkpoint storage — never from user-supplied HTTP
requests, message payloads, or other untrusted sources.
3. The ``allowed_types`` parameter is specified whenever possible to restrict
the set of reconstructible types to the minimum required by the application.
4. Never pass untrusted external input to ``decode_checkpoint_value``. If you
must accept external JSON that might contain checkpoint markers, sanitize it
first (for example, :func:`agent_framework_azurefunctions._serialization.strip_pickle_markers`).
The allowlist is a mitigation that reduces attack surface but does not
eliminate the inherent risks of deserializing untrusted pickle data. Treat
your checkpoint storage with the same access controls you would apply to
application secrets or database credentials.
"""
from __future__ import annotations
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.8.0"
version = "1.7.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -11,14 +11,10 @@ from agent_framework import (
GROUP_TOKEN_COUNT_KEY,
BaseChatClient,
ChatResponse,
ChatResponseUpdate,
Content,
Message,
SlidingWindowStrategy,
SupportsChatGetResponse,
ToolResultCompactionStrategy,
TruncationStrategy,
tool,
)
@@ -262,196 +258,6 @@ 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,64 +196,6 @@ 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"]),
@@ -542,44 +484,6 @@ 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,425 +3975,3 @@ 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
@@ -1,667 +0,0 @@
# 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,6 +1,4 @@
# 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
@@ -1348,45 +1346,6 @@ 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,7 +191,6 @@ 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.
@@ -212,8 +211,6 @@ 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,
@@ -263,11 +260,8 @@ 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=openai_client,
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
default_headers=default_headers,
instruction_role=instruction_role,
compaction_strategy=compaction_strategy,
@@ -543,7 +537,6 @@ 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.
@@ -563,8 +556,6 @@ 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,
@@ -582,7 +573,6 @@ class _FoundryAgentChatClient( # type: ignore[misc]
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
timeout=timeout,
)
@@ -635,7 +625,6 @@ 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.
@@ -668,8 +657,6 @@ 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
@@ -688,7 +675,6 @@ 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):
@@ -926,7 +912,6 @@ 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.
@@ -973,8 +958,6 @@ 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,
@@ -1000,5 +983,4 @@ class FoundryAgent( # type: ignore[misc]
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
additional_properties=additional_properties,
timeout=timeout,
)
+3 -3
View File
@@ -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.8.0"
version = "1.7.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.8.0,<2",
"agent-framework-openai>=1.8.0,<2",
"agent-framework-core>=1.7.0,<2",
"agent-framework-openai>=1.7.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -109,67 +109,9 @@ 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."""
@@ -610,29 +552,9 @@ 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."""
@@ -707,7 +629,6 @@ 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())
@@ -720,47 +641,9 @@ 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, dataclass, is_dataclass
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Protocol, cast
@@ -264,73 +264,28 @@ 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 = -32006
CONSENT_ERROR_CODE = -32007
@dataclass
class ConsentError:
name: str
consent_url: str
def consent_url_from_error(exc: BaseException) -> str | None:
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None:
"""Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors.
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``.
Args:
exc: The exception to inspect.
Returns:
The consent URL(s) extracted from the error, or ``None`` if no consent error was found.
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
"""
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:
# 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 inner_exception.error.message
return None
@@ -493,19 +448,18 @@ class ResponsesHostServer(ResponsesAgentServerHost):
try:
await self._ensure_agent_ready()
except AgentFrameworkException as ex:
consent_errors = consent_url_from_error(ex)
if consent_errors is None:
consent_url = consent_url_from_error(ex)
if consent_url is None:
raise
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)
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)
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.0a260604"
version = "1.0.0a260528"
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.8.0,<2",
"agent-framework-core>=1.7.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,7 +39,6 @@ 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]
@@ -2119,11 +2118,15 @@ 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
@@ -3261,10 +3264,7 @@ class TestCheckpointContextPathValidation:
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
def _make_consent_error(
url: str = "https://consent.example.com/auth",
name: str = "Foundry Toolbox",
) -> Exception:
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
"""Build an exception wrapping a Foundry MCP gateway consent error.
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
@@ -3272,34 +3272,17 @@ def _make_consent_error(
``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
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))
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
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", name="my-tool")
assert consent_url_from_error(exc) == [ConsentError(name="my-tool", consent_url="https://example.com/consent")]
exc = _make_consent_error("https://example.com/consent")
assert consent_url_from_error(exc) == "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
@@ -3316,13 +3299,6 @@ 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,10 +37,9 @@ from agent_framework.exceptions import AgentException
from agent_framework.observability import AgentTelemetryLayer
try:
from copilot import CopilotClient, CopilotSession, RuntimeConnection
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
from copilot import CopilotClient, CopilotSession, SubprocessConfig
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
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:
@@ -58,10 +57,8 @@ else:
DEFAULT_TIMEOUT_SECONDS: float = 60.0
"""Default timeout in seconds for Copilot requests."""
PermissionHandlerType = Callable[
[PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
]
"""Type for permission request handlers. Supports both sync and async callbacks."""
PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult]
"""Type for permission request handlers."""
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
@@ -124,7 +121,7 @@ def _deny_all_permissions(
_invocation: dict[str, str],
) -> PermissionRequestResult:
"""Default permission handler that denies all requests."""
return PermissionDecisionUserNotAvailable()
return PermissionRequestResult()
class GitHubCopilotSettings(TypedDict, total=False):
@@ -143,9 +140,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.
base_directory: Directory where the CLI stores session state, configuration,
copilot_home: Directory where the CLI stores session state, configuration,
and other persistent data. Can be set via environment variable
GITHUB_COPILOT_BASE_DIRECTORY. Defaults to ~/.copilot when not set.
GITHUB_COPILOT_COPILOT_HOME. 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).
"""
@@ -154,7 +151,7 @@ class GitHubCopilotSettings(TypedDict, total=False):
model: str | None
timeout: float | None
log_level: str | None
base_directory: str | None
copilot_home: str | None
class GitHubCopilotOptions(TypedDict, total=False):
@@ -317,7 +314,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)
base_directory = opts.pop("base_directory", None)
copilot_home = opts.pop("copilot_home", None)
self._settings = load_settings(
GitHubCopilotSettings,
@@ -326,7 +323,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
model=model,
timeout=timeout,
log_level=log_level,
base_directory=base_directory,
copilot_home=copilot_home,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
@@ -365,16 +362,14 @@ 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
base_directory = self._settings.get("base_directory") or None
copilot_home = self._settings.get("copilot_home") or None
client_kwargs: dict[str, Any] = {}
if cli_path:
client_kwargs["connection"] = RuntimeConnection.for_stdio(path=cli_path)
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
if log_level:
client_kwargs["log_level"] = log_level
if base_directory:
client_kwargs["base_directory"] = base_directory
self._client = CopilotClient(**client_kwargs)
subprocess_kwargs["log_level"] = log_level
if copilot_home:
subprocess_kwargs["copilot_home"] = copilot_home
self._client = CopilotClient(SubprocessConfig(**subprocess_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.0rc1"
version = "1.0.0b260521"
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.8.0,<2",
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
"agent-framework-core>=1.6.0,<2",
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
]
[tool.uv]
@@ -2,7 +2,6 @@
# ruff: noqa: E402
import os
import unittest.mock
from datetime import datetime, timezone
from typing import Any
@@ -21,11 +20,9 @@ from agent_framework import (
ContextProvider,
HistoryProvider,
Message,
tool,
)
from agent_framework.exceptions import AgentException
from copilot.session import PermissionHandler
from copilot.session_events import (
from copilot.generated.session_events import (
Data,
SessionEvent,
SessionEventType,
@@ -311,27 +308,27 @@ class TestGitHubCopilotAgentLifecycle:
)
await agent.start()
kwargs = MockClient.call_args.kwargs
assert kwargs["connection"].path == "/custom/path"
assert kwargs["log_level"] == "debug"
call_args = MockClient.call_args[0][0]
assert call_args.cli_path == "/custom/path"
assert call_args.log_level == "debug"
async def test_start_passes_base_directory_to_client(self) -> None:
"""Test that base_directory is passed through to CopilotClient."""
async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
"""Test that copilot_home is passed through to SubprocessConfig."""
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={"base_directory": "/custom/copilot/home"}
default_options={"copilot_home": "/custom/copilot/home"}
)
await agent.start()
kwargs = MockClient.call_args.kwargs
assert kwargs["base_directory"] == "/custom/copilot/home"
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/custom/copilot/home"
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."""
async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
@@ -340,14 +337,14 @@ class TestGitHubCopilotAgentLifecycle:
agent = GitHubCopilotAgent()
await agent.start()
kwargs = MockClient.call_args.kwargs
assert "base_directory" not in kwargs
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home is None
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."""
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."""
with (
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
patch.dict("os.environ", {"GITHUB_COPILOT_BASE_DIRECTORY": "/env/copilot/home"}),
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
):
mock_client = MagicMock()
mock_client.start = AsyncMock()
@@ -356,8 +353,8 @@ class TestGitHubCopilotAgentLifecycle:
agent = GitHubCopilotAgent()
await agent.start()
kwargs = MockClient.call_args.kwargs
assert kwargs["base_directory"] == "/env/copilot/home"
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/env/copilot/home"
class TestGitHubCopilotAgentRun:
@@ -1056,11 +1053,11 @@ class TestGitHubCopilotAgentSessionManagement:
mock_session: MagicMock,
) -> None:
"""Test that resumed session config includes tools and permission handler."""
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
return PermissionDecisionApproveOnce()
return PermissionRequestResult(kind="approved")
def my_tool(arg: str) -> str:
"""A test tool."""
@@ -1872,15 +1869,6 @@ 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()
@@ -1888,14 +1876,13 @@ 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.rpc import PermissionDecisionDeniedInteractivelyByUser
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
if request.kind == "shell":
return PermissionDecisionApproveOnce()
return PermissionDecisionDeniedInteractivelyByUser()
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"on_permission_request": approve_shell}
@@ -1908,14 +1895,13 @@ class TestGitHubCopilotAgentPermissions:
mock_session: MagicMock,
) -> None:
"""Test that session config includes permission handler when provided."""
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
if request.kind in ("shell", "read"):
return PermissionDecisionApproveOnce()
return PermissionDecisionDeniedInteractivelyByUser()
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
@@ -2719,163 +2705,3 @@ 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)
+2 -2
View File
@@ -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.0a260604"
version = "1.0.0a260505"
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.8.0,<2",
"agent-framework-core>=1.1.0,<2",
"mistralai>=2.0.0,<3",
]
@@ -385,7 +385,6 @@ 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.
@@ -407,7 +406,6 @@ 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.
"""
...
@@ -429,7 +427,6 @@ 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.
@@ -458,7 +455,6 @@ 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.
"""
...
@@ -480,7 +476,6 @@ 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.
@@ -516,8 +511,6 @@ 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:
@@ -548,7 +541,6 @@ 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
@@ -1462,21 +1454,10 @@ 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
]
@@ -1491,7 +1472,6 @@ 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]] = []
@@ -1511,10 +1491,7 @@ 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. 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()
# without its required following item"), so the reasoning branch always drops.
for content in message.contents:
match content.type:
case "text_reasoning":
@@ -1569,10 +1546,7 @@ 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.
#
# 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:
if request_uses_service_side_storage:
continue
prepared_mcp = self._prepare_content_for_openai(
message.role,
@@ -162,7 +162,6 @@ 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.
@@ -219,8 +218,6 @@ 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(
@@ -302,12 +299,8 @@ 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]
+2 -2
View File
@@ -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.8.0"
version = "1.7.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.8.0,<2",
"agent-framework-core>=1.7.0,<2",
"openai>=1.99.0,<3",
]
@@ -36,7 +36,7 @@ from agent_framework.exceptions import (
ChatClientInvalidRequestException,
SettingNotFoundError,
)
from openai import AsyncOpenAI, BadRequestError
from openai import 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, RawOpenAIChatClient
from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
from agent_framework_openai._exceptions import OpenAIContentFilterException
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
@@ -194,26 +194,6 @@ 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)
@@ -5668,79 +5648,6 @@ 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 (
UNSET,
_MISSING, # pyright: ignore[reportPrivateUsage]
_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, UNSET),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
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 (
UNSET,
_MISSING, # pyright: ignore[reportPrivateUsage]
_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, UNSET),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
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 (
UNSET,
_MISSING, # pyright: ignore[reportPrivateUsage]
_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, UNSET),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
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, Sentinel
from typing_extensions import Never
from ._base_group_chat_orchestrator import (
BaseGroupChatOrchestrator,
@@ -39,7 +39,7 @@ from ._base_group_chat_orchestrator import (
ParticipantRegistry,
)
from ._participant_output_config import (
UNSET,
_MISSING, # pyright: ignore[reportPrivateUsage]
_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 | Sentinel = UNSET,
max_stall_count: int = 3,
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, UNSET),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
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 | Sentinel = UNSET,
max_stall_count: int = 3,
max_reset_count: int | None = None,
max_round_count: int | None = None,
) -> None:
@@ -1656,10 +1656,8 @@ 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 max_stall_count is not UNSET or any(
if any(
arg is not None
for arg in [
task_ledger,
@@ -1670,6 +1668,7 @@ class MagenticBuilder:
task_ledger_plan_update_prompt,
progress_ledger_prompt,
final_answer_prompt,
max_stall_count,
max_reset_count,
max_round_count,
]
@@ -1690,7 +1689,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=resolved_max_stall_count,
max_stall_count=max_stall_count,
max_reset_count=max_reset_count,
max_round_count=max_round_count,
)
@@ -1708,7 +1707,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": resolved_max_stall_count,
"max_stall_count": max_stall_count,
"max_reset_count": max_reset_count,
"max_round_count": max_round_count,
}
@@ -8,9 +8,8 @@ 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
UNSET = Sentinel("UNSET")
_MISSING = object()
_ALL_OUTPUTS: Literal["all"] = "all"
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
_ParticipantOutputSpecifier = str | SupportsAgentRun | Executor
@@ -21,10 +20,10 @@ _WorkflowExecutorSpecifier = Executor | SupportsAgentRun
def _coalesce_output_from( # pyright: ignore[reportUnusedFunction]
*,
output_from: Any = UNSET,
output_from: Any = _MISSING,
) -> _ParticipantOutputSelection:
"""Resolve orchestration output selection to ``output_from``."""
if output_from is not UNSET:
if output_from is not _MISSING:
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 (
UNSET,
_MISSING, # pyright: ignore[reportPrivateUsage]
_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, UNSET),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
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.0rc3"
version = "1.0.0rc2"
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.8.0,<2",
"agent-framework-core>=1.6.0,<2",
]
[tool.uv]
@@ -1,6 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass
@@ -988,33 +987,6 @@ 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
@@ -1065,20 +1037,6 @@ 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
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.8.0"
version = "1.7.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.8.0",
"agent-framework-core[all]==1.7.0",
]
[dependency-groups]
@@ -5,8 +5,7 @@ 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.
- `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.
- `advanced.py` — composes multiple strategies with `TokenBudgetComposedStrategy`.
- `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`.
@@ -16,8 +15,7 @@ Run samples with:
```bash
uv run samples/02-agents/compaction/basics.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/advanced.py
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
+44 -138
View File
@@ -1,14 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any, cast
from typing import Any
from agent_framework import (
GROUP_ANNOTATION_KEY,
GROUP_TOKEN_COUNT_KEY,
SUMMARY_OF_MESSAGE_IDS_KEY,
CharacterEstimatorTokenizer,
Content,
ChatResponse,
Message,
SelectiveToolCallCompactionStrategy,
SlidingWindowStrategy,
@@ -18,48 +15,36 @@ from agent_framework import (
apply_compaction,
included_token_count,
)
from agent_framework.openai import OpenAIChatClient
from dotenv import load_dotenv
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.
"""This sample demonstrates composed in-run compaction with a token budget.
Key components:
- 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
- TokenBudgetComposedStrategy
- Sequential strategy composition
- Summarization with a SupportsChatGetResponse-compatible summarizer client
"""
def _build_long_history() -> list[Message]:
"""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."]),
]
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])])
# A few verbose planning turns to build up token pressure.
for i in range(1, 5):
def _build_long_history() -> list[Message]:
history = [Message(role="system", contents=["You are a migration copilot."])]
for i in range(1, 8):
history.append(
Message(
role="user",
contents=[f"Iteration {i}: capture migration requirements, constraints, and edge cases in detail."],
contents=[f"Iteration {i}: capture migration requirements and edge cases."],
)
)
history.append(
@@ -67,62 +52,17 @@ def _build_long_history() -> list[Message]:
role="assistant",
contents=[
(
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."
f"Iteration {i}: detailed plan with dependencies, rollback guidance, and testing details. "
"This sentence is intentionally long to create token 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, tool-using growth.
# 1. Build synthetic history representing long-running in-run growth.
messages = _build_long_history()
# 2. Configure tokenizer and measure token count before compaction.
@@ -130,35 +70,22 @@ async def main() -> None:
annotate_message_groups(messages, tokenizer=tokenizer)
budget_before = included_token_count(messages)
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.
# 3. Configure composed strategy stack.
composed = TokenBudgetComposedStrategy(
token_budget=400,
token_budget=200,
tokenizer=tokenizer,
strategies=[
SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
SummarizationStrategy(client=summarizer, target_count=3, threshold=2),
SummarizationStrategy(
client=BudgetSummaryClient(),
target_count=3,
threshold=3,
),
SlidingWindowStrategy(keep_last_groups=4),
],
)
# 5. Apply compaction and inspect the budget result.
# 4. Apply compaction and inspect the budget result.
projected = await apply_compaction(messages, strategy=composed, tokenizer=tokenizer)
budget_after = included_token_count(messages)
@@ -168,44 +95,23 @@ 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} ({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)}")
print(f"- [{msg.role}] {text_preview} ({token_count} tokens)")
if __name__ == "__main__":
asyncio.run(main())
"""
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']
Sample output:
Projected messages after compaction: 3
Included token count before compaction: 793
Included token count after compaction: 144
Projected roles: ['system', 'user', 'assistant']
Projected messages with token counts:
- [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']
- [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)
"""
@@ -1,159 +0,0 @@
# 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,7 +27,6 @@ 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
@@ -1,87 +0,0 @@
# 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_BASE_DIRECTORY` | Directory for CLI session state and config | `~/.copilot` |
| `GITHUB_COPILOT_COPILOT_HOME` | Directory for CLI session state and config | `~/.copilot` |
## Observability
@@ -19,7 +19,8 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.github import GitHubCopilotAgent
from copilot.session import PermissionHandler
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from dotenv import load_dotenv
from pydantic import Field
@@ -27,6 +28,19 @@ 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.
@@ -46,7 +60,7 @@ async def non_streaming_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent:
@@ -63,7 +77,7 @@ async def streaming_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent:
@@ -83,7 +97,7 @@ async def runtime_options_example() -> None:
agent = GitHubCopilotAgent(
instructions="Always respond in exactly 3 words.",
tools=[get_weather],
default_options={"on_permission_request": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent:
@@ -4,7 +4,8 @@
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, the agent can read from and write to files on the filesystem.
By providing a permission handler that approves "read" and/or "write" requests, 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
@@ -14,18 +15,21 @@ SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
async def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
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 = (await asyncio.to_thread(input, "Approve? (y/n): ")).strip().lower()
if request.path is not None:
print(f" Path: {request.path}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionHandler.approve_all(request, context)
return PermissionDecisionDeniedInteractivelyByUser()
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
async def main() -> None:
@@ -32,7 +32,6 @@ 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()
@@ -49,42 +48,37 @@ def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Fr
)
async def prompt_for_approval(call: Content) -> bool:
"""Async approval callback that prompts the user interactively.
def prompt_for_approval(call: Content) -> bool:
"""Synchronous approval prompt.
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 = (await asyncio.to_thread(input, " Approve this tool call? (y/n): ")).strip().lower()
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = input("Approve this tool call? (y/n): ").strip().lower()
return response in ("y", "yes")
def auto_approve(call: Content) -> bool:
"""Synchronous approval callback that always approves.
async def prompt_for_approval_async(call: Content) -> bool:
"""Async approval prompt.
Use a sync callback for simple, non-blocking decisions that don't require
I/O (e.g. checking an allow-list of tool names).
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.
"""
print(f"\n [Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
print(" -> Auto-approved")
return True
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")
async def run_with_interactive_callback() -> None:
"""Demonstrates an interactive approval prompt before tool execution."""
print("\n=== GitHub Copilot Agent: interactive approval callback ===")
async def run_with_sync_callback() -> None:
print("\n=== GitHub Copilot Agent: synchronous approval callback ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={
"on_function_approval": prompt_for_approval,
"on_permission_request": PermissionHandler.approve_all,
},
default_options={"on_function_approval": prompt_for_approval},
)
async with agent:
query = "Give me the detailed weather for Seattle."
@@ -93,16 +87,12 @@ async def run_with_interactive_callback() -> None:
print(f"Agent: {result}")
async def run_with_auto_approve_callback() -> None:
"""Demonstrates a synchronous callback that always approves."""
print("\n=== GitHub Copilot Agent: synchronous auto-approve callback ===")
async def run_with_async_callback() -> None:
print("\n=== GitHub Copilot Agent: asynchronous approval callback ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={
"on_function_approval": auto_approve,
"on_permission_request": PermissionHandler.approve_all,
},
default_options={"on_function_approval": prompt_for_approval_async},
)
async with agent:
query = "Give me the detailed weather for Tokyo."
@@ -122,7 +112,6 @@ 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."
@@ -133,8 +122,8 @@ async def run_without_callback() -> None:
async def main() -> None:
print("=== GitHub Copilot Agent: Function approval enforcement ===")
await run_with_interactive_callback()
await run_with_auto_approve_callback()
await run_with_sync_callback()
await run_with_async_callback()
await run_without_callback()
@@ -22,13 +22,24 @@ import asyncio
from pathlib import Path
from agent_framework.github import GitHubCopilotAgent
from copilot.session import PermissionHandler
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
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")
@@ -47,7 +58,7 @@ async def default_instructions_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful coding assistant.",
default_options={
"on_permission_request": PermissionHandler.approve_all,
"on_permission_request": prompt_permission,
"instruction_directories": instruction_dirs,
},
)
@@ -68,7 +79,7 @@ async def runtime_override_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant.",
default_options={
"on_permission_request": PermissionHandler.approve_all,
"on_permission_request": prompt_permission,
"instruction_directories": ["/team/shared/instructions"],
},
)
@@ -15,13 +15,24 @@ of MCP-related actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.session import MCPServerConfig, PermissionHandler
from copilot.generated.session_events import PermissionRequest
from copilot.session import MCPServerConfig, PermissionRequestResult
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")
@@ -45,7 +56,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": PermissionHandler.approve_all,
"on_permission_request": prompt_permission,
"mcp_servers": mcp_servers,
},
)
@@ -3,8 +3,9 @@
"""
GitHub Copilot Agent with Multiple Permissions
This sample demonstrates how multiple permission types are requested when GitHubCopilotAgent
performs complex tasks that require different capabilities.
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.
Available permission kinds:
- "shell": Execute shell commands
@@ -20,14 +21,23 @@ More permissions mean more potential for unintended actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
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)
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")
async def main() -> None:
@@ -35,14 +45,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": approve_and_log},
default_options={"on_permission_request": prompt_permission},
)
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}\n")
print(f"User: {query}")
result = await agent.run(query)
print(f"\nAgent: {result}\n")
print(f"Agent: {result}\n")
if __name__ == "__main__":
@@ -14,10 +14,24 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.github import GitHubCopilotAgent
from copilot.session import PermissionHandler
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
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.
@@ -37,7 +51,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": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent:
@@ -62,7 +76,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": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent:
@@ -99,7 +113,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": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent1:
@@ -121,7 +135,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": PermissionHandler.approve_all},
default_options={"on_permission_request": prompt_permission},
)
async with agent2:
@@ -14,20 +14,21 @@ 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.rpc import PermissionDecisionUserNotAvailable
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
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()
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")
async def main() -> None:
@@ -35,14 +36,14 @@ async def main() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant that can execute shell commands.",
default_options={"on_permission_request": approve_and_log},
default_options={"on_permission_request": prompt_permission},
)
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"\nAgent: {result}\n")
print(f"Agent: {result}\n")
if __name__ == "__main__":
@@ -14,20 +14,21 @@ URL fetching allows the agent to access any URL accessible from your network.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
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()
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")
async def main() -> None:
@@ -35,14 +36,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": approve_and_log},
default_options={"on_permission_request": prompt_permission},
)
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"\nAgent: {result}\n")
print(f"Agent: {result}\n")
if __name__ == "__main__":
@@ -12,7 +12,6 @@ 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

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