Compare commits

..
198 changed files with 1048 additions and 15251 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
+2 -2
View File
@@ -99,7 +99,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
<!-- Agent SDKs -->
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
<!-- M365 Agents SDK -->
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
@@ -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>
@@ -6,7 +6,6 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -2,22 +2,21 @@
// This sample shows how to create a GitHub Copilot agent with shell command permissions.
using GitHub.Copilot;
using GitHub.Copilot.Rpc;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
// Permission handler that prompts the user for approval
static Task<PermissionDecision> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
static Task<PermissionRequestResult> PromptPermission(PermissionRequest request, PermissionInvocation invocation)
{
Console.WriteLine($"\n[Permission Request: {request.Kind}]");
Console.Write("Approve? (y/n): ");
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
PermissionDecision decision = input is "Y" or "YES"
? PermissionDecision.ApproveOnce()
: PermissionDecision.Reject();
PermissionRequestResultKind kind = input is "Y" or "YES"
? PermissionRequestResultKind.Approved
: PermissionRequestResultKind.Rejected;
return Task.FromResult(decision);
return Task.FromResult(new PermissionRequestResult { Kind = kind });
}
// Create and start a Copilot client
@@ -36,7 +36,7 @@ dotnet run
You can customize the agent by providing additional configuration:
```csharp
using GitHub.Copilot;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
// Create and start a Copilot client
@@ -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.");
@@ -44,33 +44,18 @@ public static class HostedFoundryMemoryProviderScopes
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, composing
/// <see cref="HostedSessionContext.UserId"/> and <see cref="HostedSessionContext.ChatId"/> into a
/// single delimiter-safe partition key. Use this when memories should be visible only to the same
/// user within the same conversation.
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
/// only to the same user within the same conversation.
/// </summary>
/// <remarks>
/// Both identity values are opaque strings that may contain any characters, including the <c>:</c>
/// delimiter. To keep the composite key injective (so two distinct (user, chat) pairs can never
/// collide), each part is escaped (<c>\</c> becomes <c>\\</c>, then <c>:</c> becomes <c>\:</c>) before
/// being joined with a <c>::</c> separator.
/// </remarks>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
session =>
{
var ctx = GetRequiredHostedContext(session);
return new FoundryMemoryProvider.State(
new FoundryMemoryProviderScope($"{EscapeScopePart(ctx.UserId)}::{EscapeScopePart(ctx.ChatId)}"));
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
};
/// <summary>
/// Escapes special characters in a scope part so that distinct (user, chat) pairs produce distinct
/// composite scope keys. Backslashes are escaped first (<c>\</c> becomes <c>\\</c>), then colons
/// (<c>:</c> becomes <c>\:</c>), ensuring the <c>{user}::{chat}</c> format is unambiguous.
/// </summary>
private static string EscapeScopePart(string part) => part.Replace("\\", "\\\\").Replace(":", "\\:");
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
session?.GetHostedContext()
?? throw new InvalidOperationException(
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI.GitHub.Copilot;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace GitHub.Copilot;
namespace GitHub.Copilot.SDK;
/// <summary>
/// Provides extension methods for <see cref="CopilotClient"/>
@@ -9,7 +9,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using GitHub.Copilot;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -169,7 +169,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
// Subscribe to session events
using IDisposable subscription = copilotSession.On<SessionEvent>(evt =>
using IDisposable subscription = copilotSession.On(evt =>
{
switch (evt)
{
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string prompt = string.Join("\n", messages.Select(m => m.Text));
// Handle DataContent as attachments
(List<AttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
messages,
cancellationToken).ConfigureAwait(false);
@@ -262,7 +262,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
{
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
if (this._copilotClient.State != ConnectionState.Connected)
{
await this._copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
}
}
private ResumeSessionConfig CreateResumeConfig()
@@ -272,18 +275,36 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
/// <summary>
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new instance
/// with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
/// with <see cref="SessionConfig.Streaming"/> set to <c>true</c>.
/// </summary>
internal static SessionConfig CopySessionConfig(SessionConfig source)
{
SessionConfig copy = source.Clone();
copy.Streaming = true;
return copy;
return new SessionConfig
{
Model = source.Model,
ReasoningEffort = source.ReasoningEffort,
Tools = source.Tools,
SystemMessage = source.SystemMessage,
AvailableTools = source.AvailableTools,
ExcludedTools = source.ExcludedTools,
Provider = source.Provider,
OnPermissionRequest = source.OnPermissionRequest,
OnUserInputRequest = source.OnUserInputRequest,
Hooks = source.Hooks,
WorkingDirectory = source.WorkingDirectory,
ConfigDir = source.ConfigDir,
McpServers = source.McpServers,
CustomAgents = source.CustomAgents,
SkillDirectories = source.SkillDirectories,
DisabledSkills = source.DisabledSkills,
InfiniteSessions = source.InfiniteSessions,
Streaming = true
};
}
/// <summary>
/// Copies all supported properties from a source <see cref="SessionConfig"/> into a new
/// <see cref="ResumeSessionConfig"/> with <see cref="SessionConfigBase.Streaming"/> set to <c>true</c>.
/// <see cref="ResumeSessionConfig"/> with <see cref="ResumeSessionConfig.Streaming"/> set to <c>true</c>.
/// </summary>
internal static ResumeSessionConfig CopyResumeSessionConfig(SessionConfig? source)
{
@@ -300,7 +321,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
OnUserInputRequest = source?.OnUserInputRequest,
Hooks = source?.Hooks,
WorkingDirectory = source?.WorkingDirectory,
ConfigDirectory = source?.ConfigDirectory,
ConfigDir = source?.ConfigDir,
McpServers = source?.McpServers,
CustomAgents = source?.CustomAgents,
SkillDirectories = source?.SkillDirectories,
@@ -373,10 +394,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
AdditionalPropertiesDictionary<long>? additionalCounts = null;
if (usageEvent.Data.CacheWriteTokens is long cacheWriteTokens)
if (usageEvent.Data.CacheWriteTokens is double cacheWriteTokens)
{
additionalCounts ??= [];
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = cacheWriteTokens;
additionalCounts[nameof(AssistantUsageData.CacheWriteTokens)] = (long)cacheWriteTokens;
}
if (usageEvent.Data.Cost is double cost)
@@ -385,10 +406,10 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
additionalCounts[nameof(AssistantUsageData.Cost)] = (long)cost;
}
if (usageEvent.Data.Duration is TimeSpan duration)
if (usageEvent.Data.Duration is double duration)
{
additionalCounts ??= [];
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration.TotalMilliseconds;
additionalCounts[nameof(AssistantUsageData.Duration)] = (long)duration;
}
return additionalCounts;
@@ -411,7 +432,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
private static SessionConfig? GetSessionConfig(IList<AITool>? tools, string? instructions)
{
List<AIFunctionDeclaration>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunctionDeclaration>().ToList() : null;
List<AIFunction>? mappedTools = tools is { Count: > 0 } ? tools.OfType<AIFunction>().ToList() : null;
SystemMessageConfig? systemMessage = instructions is not null ? new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = instructions } : null;
if (mappedTools is null && systemMessage is null)
@@ -422,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
}
private static async Task<(List<AttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<AttachmentFile>? attachments = null;
List<UserMessageAttachmentFile>? attachments = null;
string? tempDir = null;
foreach (ChatMessage message in messages)
{
@@ -440,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new AttachmentFile
attachments.Add(new UserMessageAttachmentFile
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath)
@@ -4,7 +4,6 @@
<VersionSuffix>preview</VersionSuffix>
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -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,26 +112,24 @@ 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();
if (options?.DisableToolApproval is not true)
{
builder.UseToolApproval(options?.ToolApprovalAgentOptions);
builder.UseToolApproval();
}
if (options?.DisableOpenTelemetry is not true)
@@ -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,19 +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);
ChatClientBuilder chatClientBuilder = chatClient.AsBuilder();
if (options?.DisableNonApprovalRequiredFunctionBypassing is not true)
{
chatClientBuilder.UseNonApprovalRequiredFunctionBypassing();
}
return chatClientBuilder
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
return chatClient
.AsBuilder()
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
: null)
.UseMessageInjection()
@@ -204,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)
@@ -232,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>();
@@ -272,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);
}
@@ -101,29 +101,6 @@ public sealed class HarnessAgentOptions
/// </remarks>
public bool DisableToolApproval { get; set; }
/// <summary>
/// Gets or sets the options for the <see cref="ToolApprovalAgent"/> middleware.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the <see cref="ToolApprovalAgent"/> uses default settings.
/// This property has no effect when <see cref="DisableToolApproval"/> is <see langword="true"/>.
/// </remarks>
public ToolApprovalAgentOptions? ToolApprovalAgentOptions { get; set; }
/// <summary>
/// Gets or sets a value indicating whether bypassing of approval requests for tools that do not
/// require approval is disabled.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (the default), the underlying chat client pipeline includes the decorator
/// added by <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/> above the
/// function invocation middleware.
/// This stores automatically approved function calls for tools that do not require approval in the session
/// state when they are returned alongside tools that do, so that only tools that truly require human
/// approval are surfaced to the caller.
/// </remarks>
public bool DisableNonApprovalRequiredFunctionBypassing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the <see cref="FileMemoryProvider"/> is disabled.
/// </summary>
@@ -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) =>
{
@@ -21,18 +21,6 @@ namespace Microsoft.Agents.AI.Hosting;
/// from the ambient <see cref="HttpContext"/>.
/// </para>
/// <para>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
/// must uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// provider maps a different claim).
/// </para>
/// <para>
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
/// will then enforce strict or pass-through behavior based on its configuration.
@@ -72,24 +60,18 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the value of the
/// configured claim type from the current user's identity, or <see langword="null"/> if the HTTP
/// context is unavailable, the user is not authenticated, or the claim is not present.
/// configured claim type from the current user's identity, or <see langword="null"/> if the claim
/// is not present or the HTTP context is unavailable.
/// </returns>
/// <remarks>
/// This method only reads claims from an authenticated principal: if the current request has no
/// authenticated user, it returns <see langword="null"/> rather than trusting claims on an
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
/// multiple claims of the specified type exist, the first match is returned.
/// This method retrieves the claim value from <c>HttpContext.User.Claims</c>. If multiple claims
/// of the specified type exist, the first match is returned.
/// </remarks>
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
{
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
{
return new ValueTask<string?>((string?)null);
}
Claim? claim = user?.Claims.FirstOrDefault(c => c.Type == this._claimType);
Claim? claim = this._httpContextAccessor?
.HttpContext?
.User?.Claims.FirstOrDefault(c => c.Type == this._claimType);
return new ValueTask<string?>(claim?.Value);
}
@@ -14,30 +14,17 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
/// </summary>
/// <remarks>
/// <para>
/// Defaults to <see cref="ClaimTypes.NameIdentifier"/>, which corresponds to a stable, unique
/// subject identifier for the authenticated principal. For OpenID Connect tokens (including those
/// issued by Microsoft Entra ID), this is typically populated from the <c>sub</c> claim via the
/// default JWT inbound claim mapping. Note that <c>sub</c> is distinct from Entra's object
/// identifier (<c>oid</c>) claim; if you require the <c>oid</c> claim, or your provider does not map
/// a unique identifier onto <see cref="ClaimTypes.NameIdentifier"/>, override <see cref="ClaimType"/>
/// with the appropriate claim type.
/// </para>
/// <para>
/// <strong>Security warning:</strong> The configured claim must uniquely identify the principal
/// within the served population. Display names (<see cref="ClaimsIdentity.DefaultNameClaimType"/>
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
/// across all callers. Two distinct principals that share the same value for a non-unique claim
/// would receive the same session-isolation key and could read or overwrite one another's
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
/// Defaults to <see cref="ClaimsIdentity.DefaultNameClaimType"/>, which typically corresponds to
/// the user's name or unique identifier claim.
/// </para>
/// <para>
/// Common alternatives include:
/// <list type="bullet">
/// <item><description>A composite of tenant and subject identifiers — required for multi-tenant hosts where the subject is only unique per tenant</description></item>
/// <item><description>Custom claim types specific to your authentication provider, provided they are unique and stable</description></item>
/// <item><description><c>ClaimTypes.NameIdentifier</c> — Stable user identifier</description></item>
/// <item><description><c>ClaimTypes.Email</c> — Email address</description></item>
/// <item><description>Custom claim types specific to your authentication provider</description></item>
/// </list>
/// </para>
/// </remarks>
public string ClaimType { get; set; } = ClaimTypes.NameIdentifier;
public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType;
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@@ -20,28 +19,8 @@ public static class ServiceCollectionExtensions
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
/// <remarks>
/// <para>
/// This method requires <see cref="IHttpContextAccessor"/> to be registered in the service collection.
/// Ensure that <c>services.AddHttpContextAccessor()</c> has been called before using this method.
/// </para>
/// <para>
/// When <paramref name="options"/> is not supplied, the isolation key is derived from the
/// <see cref="ClaimTypes.NameIdentifier"/> claim, a stable unique subject identifier. For OpenID
/// Connect tokens (including Microsoft Entra ID), this is typically mapped from the <c>sub</c> claim
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
/// such as Entra's <c>oid</c>) should override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode session stores to fail.
/// </para>
/// <para>
/// <strong>Security warning:</strong> If you override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
/// the host can prove their uniqueness across all callers, because distinct principals that share the
/// same claim value would receive the same isolation key and could access one another's sessions.
/// </para>
/// </remarks>
public static IServiceCollection UseClaimsBasedSessionIsolation(
this IServiceCollection services,
@@ -49,7 +49,7 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(this.Model.Items);
if (expressionResult.Value is TableDataValue tableValue)
{
this._values = [.. tableValue.Values.Select(ToLoopValue)];
this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())];
}
else
{
@@ -99,15 +99,6 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor<Foreach>
}
}
// Power Fx wraps scalar array literals (`=[1, 2, 3]`) as `Table({Value: 1}, ...)`. Unwrap that single-column
// `Value`-record shape so `Local.LoopValue` is the scalar; multi-field and other shapes pass through unchanged.
private static FormulaValue ToLoopValue(DataValue value) =>
value is RecordDataValue record
&& record.Properties.Count == 1
&& record.Properties.TryGetValue("Value", out DataValue? singleColumn)
? singleColumn.ToFormula()
: value.ToFormula();
/// <inheritdoc/>
/// <remarks>
/// Persists the iteration cursor (<see cref="_index"/>), the materialized item snapshot
@@ -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);
}
@@ -181,36 +181,6 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableMessageInjection { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to store automatically approved function calls in the session state
/// for tools that do not require approval when they are returned alongside tools that do.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
/// items to <see cref="ToolApprovalRequestContent"/>, even for tools that do not require approval.
/// </para>
/// <para>
/// Setting this property to <see langword="true"/> injects an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
/// decorator above <see cref="FunctionInvokingChatClient"/> in the pipeline. This decorator identifies approval
/// requests for non-approval-required tools, removes them from the response, and stores them in the session.
/// On the next request, the stored items are automatically re-injected as approved, so the caller only needs
/// to handle approval requests for tools that truly require human approval.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, you can add an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseNonApprovalRequiredFunctionBypassing"/>
/// extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableNonApprovalRequiredFunctionBypassing { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -229,6 +199,5 @@ public sealed class ChatClientAgentOptions
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
EnableNonApprovalRequiredFunctionBypassing = this.EnableNonApprovalRequiredFunctionBypassing,
};
}
@@ -148,35 +148,4 @@ public static class ChatClientBuilderExtensions
{
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
/// <summary>
/// Adds an <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned above the <see cref="FunctionInvokingChatClient"/> in the pipeline
/// so that it can intercept approval requests for tools that do not require approval. When
/// <see cref="FunctionInvokingChatClient"/> converts all function calls to approval requests (because at
/// least one tool requires approval), this decorator removes the requests for non-approval-required tools,
/// stores them in the session, and automatically re-injects them as approved on the next request.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
/// <see cref="ChatClientAgentOptions.EnableNonApprovalRequiredFunctionBypassing"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> with
/// an active session, and will throw an exception if used in any other stack.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseNonApprovalRequiredFunctionBypassing(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
}
}
@@ -53,17 +53,6 @@ public static class ChatClientExtensions
{
var chatBuilder = chatClient.AsBuilder();
// NonApprovalRequiredFunctionBypassingChatClient is registered before FunctionInvokingChatClient so that
// it sits above FICC in the pipeline. ChatClientBuilder.Build applies factories in reverse order,
// making the first Use() call outermost. By adding this decorator first, the resulting pipeline is:
// NonApprovalRequiredFunctionBypassingChatClient → FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to intercept FICC's responses and remove approval requests for tools
// that don't actually require approval, storing them for automatic re-injection on the next request.
if (options?.EnableNonApprovalRequiredFunctionBypassing is true)
{
chatBuilder.Use(innerClient => new NonApprovalRequiredFunctionBypassingChatClient(innerClient));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
chatBuilder.Use((innerClient, services) =>
@@ -1,285 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that automatically removes <see cref="ToolApprovalRequestContent"/> for tools
/// that do not actually require approval, storing auto-approved results in the session for transparent
/// re-injection on the next request.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="FunctionInvokingChatClient"/> has an all-or-nothing behavior for approvals: when any tool
/// in a response is an <see cref="ApprovalRequiredAIFunction"/>, it converts all <see cref="FunctionCallContent"/>
/// items to <see cref="ToolApprovalRequestContent"/> — even for tools that do not require approval. This
/// decorator sits above <see cref="FunctionInvokingChatClient"/> in the pipeline and transparently handles
/// the non-approval-required items so callers only see approval requests for tools that truly need them.
/// </para>
/// <para>
/// On outbound responses, the decorator identifies <see cref="ToolApprovalRequestContent"/> items for tools
/// that are not wrapped in <see cref="ApprovalRequiredAIFunction"/>, removes them from the response, and
/// stores them in the session's <see cref="AgentSessionStateBag"/>. On the next inbound request, the stored
/// items are re-injected as pre-approved <see cref="ToolApprovalResponseContent"/> so that
/// <see cref="FunctionInvokingChatClient"/> can process them alongside the caller's human-approved responses.
/// </para>
/// <para>
/// This decorator requires an active <see cref="AIAgent.CurrentRunContext"/> with a non-null
/// <see cref="AgentRunContext.Session"/>. An <see cref="InvalidOperationException"/> is thrown if no
/// run context or session is available.
/// </para>
/// </remarks>
internal sealed class NonApprovalRequiredFunctionBypassingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used in <see cref="AgentSessionStateBag"/> to store pending auto-approved function calls
/// between agent runs.
/// </summary>
internal const string StateBagKey = "_autoApprovedFunctionCalls";
/// <summary>
/// Initializes a new instance of the <see cref="NonApprovalRequiredFunctionBypassingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client (typically a <see cref="FunctionInvokingChatClient"/>).</param>
public NonApprovalRequiredFunctionBypassingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
messages = InjectPendingAutoApprovals(messages, session);
var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
RemoveAutoApprovedFromMessages(response.Messages, autoApprovableNames, session);
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var autoApprovableNames = this.GetAutoApprovableToolNames(options);
messages = InjectPendingAutoApprovals(messages, session);
List<ToolApprovalRequestContent>? autoApproved = null;
try
{
await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
if (FilterUpdateContents(update, autoApprovableNames, ref autoApproved))
{
yield return update;
}
}
}
finally
{
if (autoApproved is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
}
}
}
/// <summary>
/// Gets the current <see cref="AgentSession"/> from the ambient run context.
/// </summary>
/// <exception cref="InvalidOperationException">No run context or session is available.</exception>
private static AgentSession GetRequiredSession()
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
return runContext.Session
?? throw new InvalidOperationException(
$"{nameof(NonApprovalRequiredFunctionBypassingChatClient)} requires a session. " +
"Ensure the agent has a resolved session before invoking the chat client.");
}
/// <summary>
/// Checks the session for stored auto-approvals from a previous turn and injects them as
/// a user message containing <see cref="ToolApprovalResponseContent"/> items appended to the input messages.
/// </summary>
/// <remarks>
/// All stored requests are unconditionally injected as approved responses regardless of whether the
/// tool set has changed, because the LLM requires a complete set of tool call responses for a prior turn.
/// </remarks>
private static IEnumerable<ChatMessage> InjectPendingAutoApprovals(
IEnumerable<ChatMessage> messages,
AgentSession session)
{
if (!session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
StateBagKey,
out var pendingRequests,
AgentJsonUtilities.DefaultOptions)
|| pendingRequests is not { Count: > 0 })
{
return messages;
}
session.StateBag.TryRemoveValue(StateBagKey);
List<AIContent> approvalResponses = [];
foreach (var request in pendingRequests)
{
approvalResponses.Add(request.CreateResponse(approved: true));
}
var userMessage = new ChatMessage(ChatRole.User, approvalResponses);
return messages.Concat([userMessage]);
}
/// <summary>
/// Builds a set of tool names that do not require approval and can be auto-approved,
/// by checking all available tools from <see cref="ChatOptions.Tools"/> and
/// <see cref="FunctionInvokingChatClient.AdditionalTools"/>.
/// </summary>
private HashSet<string> GetAutoApprovableToolNames(ChatOptions? options)
{
var ficc = this.GetService<FunctionInvokingChatClient>();
var allTools = (options?.Tools ?? Enumerable.Empty<AITool>())
.Concat(ficc?.AdditionalTools ?? Enumerable.Empty<AITool>());
return new HashSet<string>(
allTools
.OfType<AIFunction>()
.Where(static f => f.GetService<ApprovalRequiredAIFunction>() is null)
.Select(static f => f.Name),
StringComparer.Ordinal);
}
/// <summary>
/// Determines whether a <see cref="ToolApprovalRequestContent"/> can be auto-approved because
/// the underlying tool is not an <see cref="ApprovalRequiredAIFunction"/>.
/// </summary>
/// <returns>
/// <see langword="true"/> if the approval request is for a known tool that does not require approval
/// and can be auto-approved; <see langword="false"/> otherwise.
/// </returns>
private static bool IsAutoApprovable(ToolApprovalRequestContent approval, HashSet<string> autoApprovableNames)
{
if (approval.ToolCall is not FunctionCallContent fcc)
{
// Non-function tool calls cannot be auto-approved.
return false;
}
// Auto-approve only if the tool is known and explicitly does NOT require approval.
// Unknown tools are not in the set and are treated as approval-required (safe default).
return autoApprovableNames.Contains(fcc.Name);
}
/// <summary>
/// Scans response messages for auto-approvable <see cref="ToolApprovalRequestContent"/> items,
/// removes them from the messages, and stores them in the session for the next request.
/// </summary>
private static void RemoveAutoApprovedFromMessages(
IList<ChatMessage> messages,
HashSet<string> autoApprovableNames,
AgentSession session)
{
List<ToolApprovalRequestContent>? autoApproved = null;
foreach (var message in messages)
{
for (int i = message.Contents.Count - 1; i >= 0; i--)
{
if (message.Contents[i] is ToolApprovalRequestContent approval
&& IsAutoApprovable(approval, autoApprovableNames))
{
(autoApproved ??= []).Add(approval);
message.Contents.RemoveAt(i);
}
}
}
// Remove messages that are now empty after filtering.
for (int i = messages.Count - 1; i >= 0; i--)
{
if (messages[i].Contents.Count == 0)
{
messages.RemoveAt(i);
}
}
if (autoApproved is { Count: > 0 })
{
session.StateBag.SetValue(StateBagKey, autoApproved, AgentJsonUtilities.DefaultOptions);
}
}
/// <summary>
/// Filters auto-approvable <see cref="ToolApprovalRequestContent"/> items from a streaming update's
/// contents, collecting them for later storage.
/// </summary>
/// <returns>
/// <see langword="true"/> if the update should be yielded (has remaining content or had no
/// approval content to begin with); <see langword="false"/> if the update is now empty and
/// should be skipped.
/// </returns>
private static bool FilterUpdateContents(
ChatResponseUpdate update,
HashSet<string> autoApprovableNames,
ref List<ToolApprovalRequestContent>? autoApproved)
{
bool hasApprovalContent = false;
List<AIContent> filteredContents = [];
bool removedAny = false;
for (int i = 0; i < update.Contents.Count; i++)
{
var content = update.Contents[i];
if (content is ToolApprovalRequestContent approval)
{
hasApprovalContent = true;
if (IsAutoApprovable(approval, autoApprovableNames))
{
(autoApproved ??= []).Add(approval);
removedAny = true;
}
else
{
filteredContents.Add(content);
}
}
else
{
filteredContents.Add(content);
}
}
if (removedAny)
{
update.Contents = filteredContents;
}
// Yield the update unless it was purely auto-approvable approval content (now empty).
return update.Contents.Count > 0 || !hasApprovalContent;
}
}
@@ -51,22 +51,20 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
{
private readonly ProviderSessionState<ToolApprovalState> _sessionState;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly Func<FunctionCallContent, ValueTask<bool>>[]? _autoApprovalRules;
/// <summary>
/// Initializes a new instance of the <see cref="ToolApprovalAgent"/> class.
/// </summary>
/// <param name="innerAgent">The underlying agent to delegate to.</param>
/// <param name="options">
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
/// When <see langword="null"/>, default settings are used.
/// <param name="jsonSerializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
public ToolApprovalAgent(AIAgent innerAgent, ToolApprovalAgentOptions? options = null)
public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null)
: base(innerAgent)
{
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
this._sessionState = new ProviderSessionState<ToolApprovalState>(
_ => new ToolApprovalState(),
"toolApprovalState",
@@ -81,7 +79,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
CancellationToken cancellationToken = default)
{
// Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
if (nextQueuedItem is not null)
{
@@ -100,7 +98,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
var response = await this.InnerAgent.RunAsync(processedMessages, session, options, cancellationToken).ConfigureAwait(false);
// Classify approval requests: auto-approve matching, queue excess, keep first unapproved.
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session).ConfigureAwait(false);
bool allAutoApproved = this.ProcessAndQueueOutboundApprovalRequests(response.Messages, state, session);
if (!allAutoApproved)
{
@@ -121,7 +119,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
if (nextQueuedItem is not null)
{
@@ -199,7 +197,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
yield break;
}
// 4. Classify the collected approval requests against standing rules and auto-approval rules.
// 4. Classify the collected approval requests against standing rules.
List<ToolApprovalRequestContent> unapproved = [];
foreach (var tarc in streamedApprovalRequests)
{
@@ -208,11 +206,6 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
}
else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
}
else
{
unapproved.Add(tarc);
@@ -298,9 +291,9 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
}
/// <summary>
/// Re-evaluates queued approval requests against current rules and auto-approval rules, and auto-approves any that now match.
/// Re-evaluates queued approval requests against current rules and auto-approves any that now match.
/// </summary>
private async ValueTask DrainAutoApprovableFromQueueAsync(ToolApprovalState state)
private void DrainAutoApprovableFromQueue(ToolApprovalState state)
{
for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--)
{
@@ -310,12 +303,6 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
state.QueuedApprovalRequests.RemoveAt(i);
}
else if (await this.MatchesAutoApprovalRuleAsync(state.QueuedApprovalRequests[i]).ConfigureAwait(false))
{
state.CollectedApprovalResponses.Add(
state.QueuedApprovalRequests[i].CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
state.QueuedApprovalRequests.RemoveAt(i);
}
}
}
@@ -331,8 +318,8 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
/// A tuple of (state, processed caller messages, next queued item or <see langword="null"/> if the queue is resolved).
/// When the returned item is non-null, the caller should return/yield it without calling the inner agent.
/// </returns>
private async ValueTask<(ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)>
PrepareInboundMessagesAsync(IEnumerable<ChatMessage> messages, AgentSession? session)
private (ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)
PrepareInboundMessages(IEnumerable<ChatMessage> messages, AgentSession? session)
{
var state = this._sessionState.GetOrInitializeState(session);
@@ -350,7 +337,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
// Re-evaluate remaining queued items — the caller may have added new rules
// (e.g., "always approve this tool") that resolve additional items.
await this.DrainAutoApprovableFromQueueAsync(state).ConfigureAwait(false);
this.DrainAutoApprovableFromQueue(state);
if (state.QueuedApprovalRequests.Count > 0)
{
@@ -399,18 +386,15 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
/// <see langword="true"/> if all TARc items were auto-approved (caller should re-invoke the inner agent);
/// <see langword="false"/> otherwise.
/// </returns>
private async ValueTask<bool> ProcessAndQueueOutboundApprovalRequestsAsync(
private bool ProcessAndQueueOutboundApprovalRequests(
IList<ChatMessage> responseMessages,
ToolApprovalState state,
AgentSession? session)
{
// Pass 1: Scan all response messages and classify each approval request.
// Auto-approved requests (matching a standing rule or auto-approval rule) have their
// responses collected immediately, preserving the original request order, and are
// marked for removal. Unapproved requests are collected for the caller to decide.
var toRemove = new HashSet<ToolApprovalRequestContent>();
// Pass 1: Scan all response messages and classify each approval request as
// auto-approved (matches a standing rule) or unapproved (needs caller decision).
var autoApproved = new List<ToolApprovalRequestContent>();
var unapproved = new List<ToolApprovalRequestContent>();
int autoApprovedCount = 0;
foreach (var message in responseMessages)
{
@@ -420,17 +404,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
{
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
toRemove.Add(tarc);
autoApprovedCount++;
}
else if (await this.MatchesAutoApprovalRuleAsync(tarc).ConfigureAwait(false))
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by auto-approval rule"));
toRemove.Add(tarc);
autoApprovedCount++;
autoApproved.Add(tarc);
}
else
{
@@ -441,12 +415,18 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
}
// Nothing to process: no auto-approved items and at most one unapproved (no queueing needed).
// No responses were collected above in this case, so state is unmodified and safe to leave.
if (autoApprovedCount == 0 && unapproved.Count <= 1)
if (autoApproved.Count == 0 && unapproved.Count <= 1)
{
return false;
}
// Store auto-approved responses for later injection into the inner agent.
foreach (var tarc in autoApproved)
{
state.CollectedApprovalResponses.Add(
tarc.CreateResponse(approved: true, reason: "Auto-approved by standing rule"));
}
// If every approval request was auto-approved, strip them all and signal the caller
// to re-invoke the inner agent immediately with the collected responses.
if (unapproved.Count == 0)
@@ -459,10 +439,14 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
// Pass 2: Keep only the first unapproved request in the response (for the caller to decide).
// Queue the remaining unapproved requests for subsequent one-at-a-time delivery.
// Remove all auto-approved and queued items from the response messages.
for (int i = 1; i < unapproved.Count; i++)
var toRemove = new HashSet<ToolApprovalRequestContent>(autoApproved);
if (unapproved.Count > 1)
{
toRemove.Add(unapproved[i]);
state.QueuedApprovalRequests.Add(unapproved[i]);
for (int i = 1; i < unapproved.Count; i++)
{
toRemove.Add(unapproved[i]);
state.QueuedApprovalRequests.Add(unapproved[i]);
}
}
// Walk messages in reverse and strip marked items.
@@ -679,36 +663,8 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
}
/// <summary>
/// Checks whether a <see cref="ToolApprovalRequestContent"/> is approved by any of the configured
/// auto-approval rules (heuristic functions).
/// Compares stored rule arguments against actual function call arguments for an exact match.
/// </summary>
/// <returns>
/// <see langword="true"/> if any auto-approval rule returns <see langword="true"/> for the function call;
/// <see langword="false"/> if no rules are configured, the request is not a function call, or no rule approves it.
/// </returns>
private async ValueTask<bool> MatchesAutoApprovalRuleAsync(ToolApprovalRequestContent request)
{
if (this._autoApprovalRules is not { Length: > 0 })
{
return false;
}
if (request.ToolCall is not FunctionCallContent functionCall)
{
return false;
}
foreach (var rule in this._autoApprovalRules)
{
if (await rule(functionCall).ConfigureAwait(false))
{
return true;
}
}
return false;
}
private static bool ArgumentsMatch(IDictionary<string, string> ruleArguments, IDictionary<string, object?>? callArguments, JsonSerializerOptions jsonSerializerOptions)
{
if (callArguments is null)
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
@@ -16,9 +17,9 @@ public static class ToolApprovalAgentBuilderExtensions
/// Adds tool approval middleware to the agent pipeline, enabling "don't ask again" approval behavior.
/// </summary>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to which tool approval support will be added.</param>
/// <param name="options">
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
/// When <see langword="null"/>, default settings are used.
/// <param name="jsonSerializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used for serializing argument values when storing rules
/// and for persisting state. When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
/// </param>
/// <returns>The <see cref="AIAgentBuilder"/> with tool approval middleware added, enabling method chaining.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
@@ -31,6 +32,6 @@ public static class ToolApprovalAgentBuilderExtensions
/// </remarks>
public static AIAgentBuilder UseToolApproval(
this AIAgentBuilder builder,
ToolApprovalAgentOptions? options = null)
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, options));
JsonSerializerOptions? jsonSerializerOptions = null)
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions));
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Options for configuring the <see cref="ToolApprovalAgent"/> middleware.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public class ToolApprovalAgentOptions
{
/// <summary>
/// Gets or sets the <see cref="System.Text.Json.JsonSerializerOptions"/> used for serializing argument values
/// when storing rules and for persisting state.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, <see cref="AgentJsonUtilities.DefaultOptions"/> is used.
/// </remarks>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
/// <summary>
/// Gets or sets a collection of heuristic functions that can automatically approve function calls
/// that would otherwise require user approval.
/// </summary>
/// <remarks>
/// <para>
/// Each function receives a <see cref="FunctionCallContent"/> representing the tool call that requires approval
/// and returns a <see cref="ValueTask{Boolean}"/> that resolves to <see langword="true"/> to auto-approve
/// the call, or <see langword="false"/> to continue evaluating the next rule.
/// </para>
/// <para>
/// Auto-approval rules are evaluated after standing rules (derived from prior user approvals) but before
/// prompting the user. Rules are evaluated in order; the first rule returning <see langword="true"/>
/// causes the function call to be auto-approved.
/// </para>
/// </remarks>
public IEnumerable<Func<FunctionCallContent, ValueTask<bool>>>? AutoApprovalRules { get; set; }
}
@@ -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();
}
@@ -182,7 +182,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
}
}
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
[RetryFact(2, 5000)]
public async Task WorkflowEventsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -278,7 +278,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
[RetryFact(2, 5000)]
public async Task WorkflowSharedStateSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -376,7 +376,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
[RetryFact(2, 5000)]
public async Task SubWorkflowsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -452,7 +452,7 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[RetryFact(2, 5000, Skip = "KeyNotFoundException in workflow execution. See https://github.com/microsoft/agent-framework/issues/6404")]
[RetryFact(2, 5000)]
public async Task WorkflowHITLSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(s_testTimeout);
@@ -43,7 +43,7 @@ public class HostedFoundryMemoryProviderScopesTests
}
[Fact]
public void PerUserAndChat_ComposesUserAndChatWithEscapedSeparator()
public void PerUserAndChat_ComposesUserAndChatWithColon()
{
// Arrange
var session = CreateTaggedSession(TestUserId, TestChatId);
@@ -54,51 +54,7 @@ public class HostedFoundryMemoryProviderScopesTests
// Assert
Assert.NotNull(state);
Assert.Equal($"{TestUserId}::{TestChatId}", state.Scope.Scope);
}
[Fact]
public void PerUserAndChat_EscapesColonsInUserAndChat()
{
// Arrange
var session = CreateTaggedSession("alice:finance", "q2:final");
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
// Act
var state = initializer(session);
// Assert - colons inside each part are escaped as \: , parts joined with ::
Assert.Equal(@"alice\:finance::q2\:final", state.Scope.Scope);
}
[Fact]
public void PerUserAndChat_EscapesBackslashesInUserAndChat()
{
// Arrange
var session = CreateTaggedSession(@"alice\corp", @"chat\1");
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
// Act
var state = initializer(session);
// Assert - backslashes escaped first as \\ , parts joined with ::
Assert.Equal(@"alice\\corp::chat\\1", state.Scope.Scope);
}
[Fact]
public void PerUserAndChat_DistinctContextsDoNotCollide()
{
// Arrange - two distinct (UserId, ChatId) pairs that collide under raw-colon composition.
var sessionA = CreateTaggedSession("alice:finance", "q2");
var sessionB = CreateTaggedSession("alice", "finance:q2");
var initializer = HostedFoundryMemoryProviderScopes.PerUserAndChat();
// Act
var scopeA = initializer(sessionA).Scope.Scope;
var scopeB = initializer(sessionB).Scope.Scope;
// Assert
Assert.NotEqual(scopeA, scopeB);
Assert.Equal($"{TestUserId}:{TestChatId}", state.Scope.Scope);
}
[Fact]
@@ -4,8 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GitHub.Copilot;
using GitHub.Copilot.Rpc;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
@@ -14,8 +13,8 @@ public class GitHubCopilotAgentTests
{
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
private static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(PermissionDecision.ApproveOnce());
private static Task<PermissionRequestResult> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
[Fact(Skip = SkipReason)]
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
@@ -3,7 +3,6 @@
<PropertyGroup>
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -2,7 +2,7 @@
using System;
using System.Collections.Generic;
using GitHub.Copilot;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
@@ -16,7 +16,7 @@ public sealed class CopilotClientExtensionsTests
public void AsAIAgent_WithAllParameters_ReturnsGitHubCopilotAgentWithSpecifiedProperties()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
const string TestId = "test-agent-id";
const string TestName = "Test Agent";
@@ -37,7 +37,7 @@ public sealed class CopilotClientExtensionsTests
public void AsAIAgent_WithMinimalParameters_ReturnsGitHubCopilotAgent()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
// Act
var agent = copilotClient.AsAIAgent(ownsClient: false, tools: null);
@@ -61,7 +61,7 @@ public sealed class CopilotClientExtensionsTests
public void AsAIAgent_WithOwnsClient_ReturnsAgentThatOwnsClient()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
// Act
var agent = copilotClient.AsAIAgent(ownsClient: true, tools: null);
@@ -75,7 +75,7 @@ public sealed class CopilotClientExtensionsTests
public void AsAIAgent_WithTools_ReturnsAgentWithTools()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
// Act
@@ -3,8 +3,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GitHub.Copilot;
using GitHub.Copilot.Rpc;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
@@ -18,7 +17,7 @@ public sealed class GitHubCopilotAgentTests
public void Constructor_WithCopilotClient_InitializesPropertiesCorrectly()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
const string TestId = "test-id";
const string TestName = "test-name";
const string TestDescription = "test-description";
@@ -43,7 +42,7 @@ public sealed class GitHubCopilotAgentTests
public void Constructor_WithDefaultParameters_UsesBaseProperties()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
// Act
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
@@ -59,7 +58,7 @@ public sealed class GitHubCopilotAgentTests
public async Task CreateSessionAsync_ReturnsGitHubCopilotAgentSessionAsync()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
// Act
@@ -74,7 +73,7 @@ public sealed class GitHubCopilotAgentTests
public async Task CreateSessionAsync_WithSessionId_ReturnsSessionWithSessionIdAsync()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
const string TestSessionId = "test-session-id";
@@ -91,7 +90,7 @@ public sealed class GitHubCopilotAgentTests
public void Constructor_WithTools_InitializesCorrectly()
{
// Arrange
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
List<AITool> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
// Act
@@ -106,12 +105,12 @@ public sealed class GitHubCopilotAgentTests
public void CopySessionConfig_CopiesAllProperties()
{
// Arrange
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
var hooks = new SessionHooks();
var infiniteSessions = new InfiniteSessionConfig();
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
@@ -123,7 +122,7 @@ public sealed class GitHubCopilotAgentTests
AvailableTools = ["tool1", "tool2"],
ExcludedTools = ["tool3"],
WorkingDirectory = "/workspace",
ConfigDirectory = "/config",
ConfigDir = "/config",
Hooks = hooks,
InfiniteSessions = infiniteSessions,
OnPermissionRequest = permissionHandler,
@@ -138,15 +137,17 @@ public sealed class GitHubCopilotAgentTests
// Assert
Assert.Equal("gpt-4o", result.Model);
Assert.Equal("high", result.ReasoningEffort);
Assert.Equal(systemMessage, result.SystemMessage);
Assert.Same(tools, result.Tools);
Assert.Same(systemMessage, result.SystemMessage);
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
Assert.Equal("/workspace", result.WorkingDirectory);
Assert.Equal("/config", result.ConfigDirectory);
Assert.Equal("/config", result.ConfigDir);
Assert.Same(hooks, result.Hooks);
Assert.Same(infiniteSessions, result.InfiniteSessions);
Assert.Same(permissionHandler, result.OnPermissionRequest);
Assert.Same(userInputHandler, result.OnUserInputRequest);
Assert.Same(mcpServers, result.McpServers);
Assert.Equal(new List<string> { "skill1" }, result.DisabledSkills);
Assert.True(result.Streaming);
}
@@ -155,12 +156,12 @@ public sealed class GitHubCopilotAgentTests
public void CopyResumeSessionConfig_CopiesAllProperties()
{
// Arrange
List<AIFunctionDeclaration> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
List<AIFunction> tools = [AIFunctionFactory.Create(() => "test", "TestFunc", "Test function")];
var hooks = new SessionHooks();
var infiniteSessions = new InfiniteSessionConfig();
var systemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = "Be helpful" };
Func<PermissionRequest, PermissionInvocation, Task<PermissionDecision>> permissionHandler = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce());
Func<UserInputRequest, UserInputInvocation, Task<UserInputResponse>> userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
PermissionRequestHandler permissionHandler = (_, _) => Task.FromResult(new PermissionRequestResult());
UserInputHandler userInputHandler = (_, _) => Task.FromResult(new UserInputResponse { Answer = "input" });
var mcpServers = new Dictionary<string, McpServerConfig> { ["server1"] = new McpStdioServerConfig() };
var source = new SessionConfig
@@ -172,7 +173,7 @@ public sealed class GitHubCopilotAgentTests
AvailableTools = ["tool1", "tool2"],
ExcludedTools = ["tool3"],
WorkingDirectory = "/workspace",
ConfigDirectory = "/config",
ConfigDir = "/config",
Hooks = hooks,
InfiniteSessions = infiniteSessions,
OnPermissionRequest = permissionHandler,
@@ -192,7 +193,7 @@ public sealed class GitHubCopilotAgentTests
Assert.Equal(new List<string> { "tool1", "tool2" }, result.AvailableTools);
Assert.Equal(new List<string> { "tool3" }, result.ExcludedTools);
Assert.Equal("/workspace", result.WorkingDirectory);
Assert.Equal("/config", result.ConfigDirectory);
Assert.Equal("/config", result.ConfigDir);
Assert.Same(hooks, result.Hooks);
Assert.Same(infiniteSessions, result.InfiniteSessions);
Assert.Same(permissionHandler, result.OnPermissionRequest);
@@ -217,7 +218,7 @@ public sealed class GitHubCopilotAgentTests
Assert.Null(result.OnUserInputRequest);
Assert.Null(result.Hooks);
Assert.Null(result.WorkingDirectory);
Assert.Null(result.ConfigDirectory);
Assert.Null(result.ConfigDir);
Assert.True(result.Streaming);
}
@@ -232,7 +233,7 @@ public sealed class GitHubCopilotAgentTests
Content = "Some streamed content that was already delivered via delta events"
}
};
CopilotClient copilotClient = new(new CopilotClientOptions());
CopilotClient copilotClient = new(new CopilotClientOptions { AutoStart = false });
const string TestId = "agent-id";
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: TestId, tools: null);
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(assistantMessage);
@@ -3,7 +3,6 @@
<PropertyGroup>
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);GHCP001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -27,7 +27,6 @@ public class HarnessAgentOptionsTests
Assert.Null(options.ChatHistoryProvider);
Assert.Null(options.AIContextProviders);
Assert.False(options.DisableToolApproval);
Assert.False(options.DisableNonApprovalRequiredFunctionBypassing);
Assert.False(options.DisableFileMemory);
Assert.False(options.DisableFileAccess);
Assert.False(options.DisableWebSearch);
@@ -81,7 +80,6 @@ public class HarnessAgentOptionsTests
AIContextProviders = contextProviders,
MaximumIterationsPerRequest = 42,
DisableToolApproval = true,
DisableNonApprovalRequiredFunctionBypassing = true,
DisableFileMemory = true,
FileMemoryStore = fileMemoryStore,
DisableFileAccess = true,
@@ -114,7 +112,6 @@ public class HarnessAgentOptionsTests
Assert.Same(contextProviders, options.AIContextProviders);
Assert.Equal(42, options.MaximumIterationsPerRequest);
Assert.True(options.DisableToolApproval);
Assert.True(options.DisableNonApprovalRequiredFunctionBypassing);
Assert.True(options.DisableFileMemory);
Assert.Same(fileMemoryStore, options.FileMemoryStore);
Assert.True(options.DisableFileAccess);
@@ -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;
@@ -644,142 +643,6 @@ public class HarnessAgentTests
Assert.Null(agent.GetService<ToolApprovalAgent>());
}
/// <summary>
/// Verify that ToolApprovalAgentOptions auto-approval rules are passed through and actually used.
/// </summary>
[Fact]
public async Task ToolApproval_AutoApprovalRulesAreAppliedAsync()
{
// Arrange — inner client returns an approval request on first call, then final response on second.
var callCount = 0;
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new ChatResponse(new ChatMessage(ChatRole.Assistant, [approvalRequest]));
}
return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done"));
});
var options = CreateAllDisabledOptions();
options.DisableToolApproval = false;
options.ToolApprovalAgentOptions = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — the auto-approval rule approved the request, so we get "Done" (not an approval request)
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
}
#endregion
#region Feature: NonApprovalRequiredFunctionBypassing
/// <summary>
/// Verify that by default, when a response contains a mix of tools that require approval and tools that do not,
/// only the approval-required tool is surfaced to the caller. The non-approval-required tool is bypassed
/// (stored as auto-approved) by the <c>NonApprovalRequiredFunctionBypassingChatClient</c> decorator.
/// </summary>
[Fact]
public async Task NonApprovalRequiredFunctionBypassing_BypassesNonApprovalToolsByDefaultAsync()
{
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call1", "NormalTool"),
new FunctionCallContent("call2", "ApprovalTool"),
])));
// Disable ToolApproval so the approval requests surface in the response instead of being handled.
var options = CreateAllDisabledOptions();
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — only the approval-required tool surfaces as an approval request; the normal tool is bypassed.
var approvalRequests = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.ToList();
var approvalRequest = Assert.Single(approvalRequests);
Assert.Equal("ApprovalTool", Assert.IsType<FunctionCallContent>(approvalRequest.ToolCall).Name);
}
/// <summary>
/// Verify that when bypassing is disabled, all tools (including those that do not require approval) are surfaced
/// as approval requests, reflecting the all-or-nothing behavior of <see cref="FunctionInvokingChatClient"/>.
/// </summary>
[Fact]
public async Task NonApprovalRequiredFunctionBypassing_SurfacesAllApprovalsWhenDisabledAsync()
{
// Arrange — the model requests both a normal tool and an approval-required tool in the same turn.
var normalTool = AIFunctionFactory.Create(() => "result", "NormalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "ApprovalTool"));
var mockClient = new Mock<IChatClient>();
mockClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ChatResponse(new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("call1", "NormalTool"),
new FunctionCallContent("call2", "ApprovalTool"),
])));
var options = CreateAllDisabledOptions();
options.DisableNonApprovalRequiredFunctionBypassing = true;
options.ChatOptions = new ChatOptions { Tools = [normalTool, approvalTool] };
var agent = new HarnessAgent(mockClient.Object, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
var session = await agent.CreateSessionAsync();
// Act
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — both tools surface as approval requests because bypassing is disabled.
var approvalRequests = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.Select(r => ((FunctionCallContent)r.ToolCall).Name)
.ToList();
Assert.Equal(2, approvalRequests.Count);
Assert.Contains("NormalTool", approvalRequests);
Assert.Contains("ApprovalTool", approvalRequests);
}
#endregion
#region Feature: OpenTelemetry
@@ -1597,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
}
@@ -60,7 +60,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
await Task.CompletedTask;
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task SingleAgentSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
@@ -148,7 +148,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task MultiAgentOrchestrationConcurrentSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
@@ -198,7 +198,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task MultiAgentOrchestrationConditionalsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
@@ -216,7 +216,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
@@ -272,7 +272,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task LongRunningToolsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
@@ -362,7 +362,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task AgentAsMcpToolAsync()
{
string samplePath = Path.Combine(s_samplesPath, "07_AgentAsMcpTool");
@@ -402,7 +402,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[RetryFact(2, 5000, Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[RetryFact(2, 5000)]
public async Task ReliableStreamingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
@@ -62,7 +62,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
return default;
}
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[Fact]
public async Task SequentialWorkflowSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
@@ -168,7 +168,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[Fact]
public async Task HITLWorkflowSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL");
@@ -277,7 +277,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[Fact]
public async Task WorkflowMcpToolSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowMcpTool");
@@ -333,7 +333,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[Fact]
public async Task WorkflowAndAgentsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowAndAgents");
@@ -385,7 +385,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
[Fact(Skip = "Azure Functions Core Tools v4 cannot auto-detect worker runtime in CI. See https://github.com/microsoft/agent-framework/issues/6402")]
[Fact]
public async Task ConcurrentWorkflowSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
@@ -16,7 +16,6 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
private const string TestUserId = "test-user-id";
private const string CustomClaimType = "custom-claim-type";
private const string CustomClaimValue = "custom-claim-value";
private const string TestAuthenticationType = "TestAuth";
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
@@ -102,25 +101,6 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal(TestUserId, result);
}
/// <summary>
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
/// non-unique display name claim. This guards against the session-isolation collision described in
/// the security report where two principals sharing the same name claim received the same key.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
{
// Arrange - only a display-name claim is present; the default provider must not use it.
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
@@ -128,7 +108,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Null(result);
Assert.Equal(TestUserId, result);
}
/// <summary>
@@ -211,10 +191,10 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
const string SecondValue = "second-value";
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, FirstValue),
new Claim(ClaimTypes.NameIdentifier, SecondValue),
new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue),
new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue),
};
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext
@@ -239,7 +219,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
@@ -249,66 +229,6 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
Assert.Equal(string.Empty, result);
}
/// <summary>
/// Regression test for the session-isolation collision security report: two distinct authenticated
/// principals that share the same display-name claim but have different stable identifiers and tenants
/// must produce distinct isolation keys under the default options.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
{
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
const string CommonName = "John Doe";
var principalA = CreatePrincipal(
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
new Claim(ClaimTypes.NameIdentifier, "oid-user-a"),
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-a"));
var principalB = CreatePrincipal(
new Claim(ClaimsIdentity.DefaultNameClaimType, CommonName),
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.Equal("oid-user-a", principalAKey);
Assert.Equal("oid-user-b", principalBKey);
Assert.NotEqual(principalAKey, principalBKey);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
/// even if a claim of the configured type is present. The provider must not derive an isolation key
/// from claims on an unauthenticated identity.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
{
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
var unauthenticatedIdentity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
var httpContext = new DefaultHttpContext { User = principal };
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
// Assert
Assert.False(unauthenticatedIdentity.IsAuthenticated);
Assert.Null(result);
}
#endregion
#region Helper Methods
@@ -316,7 +236,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
private void SetupHttpContextWithClaim(string claimType, string claimValue)
{
var claims = new[] { new Claim(claimType, claimValue) };
var identity = new ClaimsIdentity(claims, TestAuthenticationType);
var identity = new ClaimsIdentity(claims);
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext
@@ -327,8 +247,5 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
}
private static ClaimsPrincipal CreatePrincipal(params Claim[] claims)
=> new(new ClaimsIdentity(claims, TestAuthenticationType));
#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]
@@ -134,7 +134,6 @@ public class ChatClientAgentOptionsTests
ClearOnChatHistoryProviderConflict = false,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
EnableNonApprovalRequiredFunctionBypassing = true,
};
// Act
@@ -151,7 +150,6 @@ public class ChatClientAgentOptionsTests
Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict);
Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict);
Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict);
Assert.Equal(original.EnableNonApprovalRequiredFunctionBypassing, clone.EnableNonApprovalRequiredFunctionBypassing);
// ChatOptions should be cloned, not the same reference
Assert.NotSame(original.ChatOptions, clone.ChatOptions);
@@ -1,574 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
public class NonApprovalRequiredFunctionBypassingChatClientTests
{
#region GetResponseAsync Tests
[Fact]
public async Task GetResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var response = await RunWithAgentContextAsync(decorator, session);
// Assert
Assert.Single(response.Messages);
Assert.Equal("Hello", response.Messages[0].Text);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_AllToolsRequireApproval_PassesThroughUnchangedAsync()
{
// Arrange
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fcc = new FunctionCallContent("call1", "approvalTool");
var approval = new ToolApprovalRequestContent("req1", fcc);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, [approval])])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [approvalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — approval request should remain
Assert.Single(response.Messages);
var contents = response.Messages[0].Contents;
Assert.Single(contents);
Assert.IsType<ToolApprovalRequestContent>(contents[0]);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_MixedApproval_RemovesNonApprovalItemsAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — only the approval-required item remains in the response
Assert.Single(response.Messages);
var contents = response.Messages[0].Contents;
Assert.Single(contents);
var remainingApproval = Assert.IsType<ToolApprovalRequestContent>(contents[0]);
Assert.Equal("req2", remainingApproval.RequestId);
}
[Fact]
public async Task GetResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal, approvalRequired])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the auto-approved item should be stored in the session
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
Assert.NotNull(stored);
Assert.Single(stored!);
Assert.Equal("req1", stored![0].RequestId);
}
[Fact]
public async Task GetResponseAsync_AllNonApproval_RemovesAllApprovalsAndRemovesEmptyMessageAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var fccNormal = new FunctionCallContent("call1", "normalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalNormal])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — the message should be removed since it's now empty
Assert.Empty(response.Messages);
}
[Fact]
public async Task GetResponseAsync_NextRequest_InjectsStoredAutoApprovalsAsync()
{
// Arrange
var fccNormal = new FunctionCallContent("call1", "normalTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient((messages, _, _) =>
{
capturedMessages = messages.ToList();
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
});
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the inner client should receive injected messages
Assert.NotNull(capturedMessages);
var messagesList = capturedMessages!.ToList();
// Original user message + user message with approved responses.
Assert.Equal(2, messagesList.Count);
Assert.Equal(ChatRole.User, messagesList[0].Role);
// User message with the auto-approved ToolApprovalResponseContent
Assert.Equal(ChatRole.User, messagesList[1].Role);
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(userContent);
Assert.Equal("req1", userContent[0].RequestId);
Assert.True(userContent[0].Approved);
}
[Fact]
public async Task GetResponseAsync_NextRequest_ClearsStoredAfterInjectionAsync()
{
// Arrange
var fccNormal = new FunctionCallContent("call1", "normalTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccNormal);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var options = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "result", "normalTool")] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the stored data should be cleared after successful injection
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
}
[Fact]
public async Task GetResponseAsync_UnknownTool_TreatedAsApprovalRequiredAsync()
{
// Arrange — tool is not in ChatOptions.Tools
var fccUnknown = new FunctionCallContent("call1", "unknownTool");
var approvalUnknown = new ToolApprovalRequestContent("req1", fccUnknown);
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([
new ChatMessage(ChatRole.Assistant, [approvalUnknown])
])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [] };
// Act
var response = await RunWithAgentContextAsync(decorator, session, options);
// Assert — unknown tool should NOT be auto-approved
Assert.Single(response.Messages);
Assert.Single(response.Messages[0].Contents);
Assert.IsType<ToolApprovalRequestContent>(response.Messages[0].Contents[0]);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetResponseAsync_StoredRequestToolSetChanged_StillInjectsAsApprovedAsync()
{
// Arrange — tool was previously non-approval-required but is now wrapped in ApprovalRequiredAIFunction.
// The LLM still requires a complete set of responses, so we inject unconditionally.
var fccTool = new FunctionCallContent("call1", "changingTool");
var storedApproval = new ToolApprovalRequestContent("req1", fccTool);
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey,
new List<ToolApprovalRequestContent> { storedApproval },
AgentJsonUtilities.DefaultOptions);
IEnumerable<ChatMessage>? capturedMessages = null;
var innerClient = CreateMockChatClient((messages, _, _) =>
{
capturedMessages = messages.ToList();
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
});
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
// The tool is now wrapped in ApprovalRequiredAIFunction — but we still inject unconditionally
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "changingTool"));
var options = new ChatOptions { Tools = [approvalTool] };
// Act
await RunWithAgentContextAsync(decorator, session, options);
// Assert — the stored request should still be injected as approved
Assert.NotNull(capturedMessages);
var messagesList = capturedMessages!.ToList();
Assert.Equal(2, messagesList.Count);
var userContent = messagesList[1].Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Single(userContent);
Assert.Equal("req1", userContent[0].RequestId);
Assert.True(userContent[0].Approved);
// Session should be cleared
Assert.False(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out _, AgentJsonUtilities.DefaultOptions));
}
#endregion
#region GetStreamingResponseAsync Tests
[Fact]
public async Task GetStreamingResponseAsync_NoApprovalContent_PassesThroughUnchangedAsync()
{
// Arrange
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "Hello")));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates);
// Assert
Assert.Single(updates);
Assert.Equal("Hello", updates[0].Text);
Assert.Equal(0, session.StateBag.Count);
}
[Fact]
public async Task GetStreamingResponseAsync_MixedApproval_FiltersNonApprovalItemsAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "text"),
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — text update + filtered approval update
Assert.Equal(2, updates.Count);
Assert.Equal("text", updates[0].Text);
// Second update should only have the approval-required item
var approvalContents = updates[1].Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(approvalContents);
Assert.Equal("req2", approvalContents[0].RequestId);
}
[Fact]
public async Task GetStreamingResponseAsync_MixedApproval_StoresAutoApprovedInSessionAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var approvalTool = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "result", "approvalTool"));
var fccNormal = new FunctionCallContent("call1", "normalTool");
var fccApproval = new FunctionCallContent("call2", "approvalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var approvalRequired = new ToolApprovalRequestContent("req2", fccApproval);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate { Contents = [approvalNormal, approvalRequired] }));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool, approvalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the auto-approved item should be stored in the session
Assert.True(session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
NonApprovalRequiredFunctionBypassingChatClient.StateBagKey, out var stored, AgentJsonUtilities.DefaultOptions));
Assert.NotNull(stored);
Assert.Single(stored!);
Assert.Equal("req1", stored![0].RequestId);
}
[Fact]
public async Task GetStreamingResponseAsync_AllNonApproval_SkipsEmptyUpdateAsync()
{
// Arrange
var normalTool = AIFunctionFactory.Create(() => "result", "normalTool");
var fccNormal = new FunctionCallContent("call1", "normalTool");
var approvalNormal = new ToolApprovalRequestContent("req1", fccNormal);
var innerClient = CreateMockStreamingChatClient((_, _, _) =>
ToAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "text"),
new ChatResponseUpdate { Contents = [approvalNormal] }));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
var session = new ChatClientAgentSession();
var options = new ChatOptions { Tools = [normalTool] };
// Act
var updates = new List<ChatResponseUpdate>();
await RunStreamingWithAgentContextAsync(decorator, session, updates, options);
// Assert — the approval update should be skipped entirely
Assert.Single(updates);
Assert.Equal("text", updates[0].Text);
}
#endregion
#region Error Handling Tests
[Fact]
public async Task GetResponseAsync_NoRunContext_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
// Act & Assert — calling directly without agent context
await Assert.ThrowsAsync<InvalidOperationException>(
() => decorator.GetResponseAsync([new ChatMessage(ChatRole.User, "test")]));
}
[Fact]
public async Task GetResponseAsync_NoSession_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var innerClient = CreateMockChatClient((_, _, _) =>
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "response")])));
var decorator = new NonApprovalRequiredFunctionBypassingChatClient(innerClient);
// Act & Assert — run with null session
await Assert.ThrowsAsync<InvalidOperationException>(
() => RunWithAgentContextAsync(decorator, session: null!));
}
#endregion
#region Builder Extension Tests
[Fact]
public void UseNonApprovalRequiredFunctionBypassing_AddsDecoratorToPipeline()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
// Act
var pipeline = innerClient.AsBuilder()
.UseNonApprovalRequiredFunctionBypassing()
.Build();
// Assert
Assert.NotNull(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
}
[Fact]
public void WithDefaultAgentMiddleware_EnableNonApprovalRequiredFunctionBypassing_InjectsDecorator()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
var options = new ChatClientAgentOptions { EnableNonApprovalRequiredFunctionBypassing = true };
// Act
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
// Assert
Assert.NotNull(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
}
[Fact]
public void WithDefaultAgentMiddleware_EnableNonApprovalRequiredFunctionBypassingFalse_DoesNotInjectDecorator()
{
// Arrange
var innerClient = new Mock<IChatClient>().Object;
var options = new ChatClientAgentOptions { EnableNonApprovalRequiredFunctionBypassing = false };
// Act
var pipeline = innerClient.WithDefaultAgentMiddleware(options);
// Assert
Assert.Null(pipeline.GetService<NonApprovalRequiredFunctionBypassingChatClient>());
}
#endregion
#region Helpers
private static async Task<ChatResponse> RunWithAgentContextAsync(
NonApprovalRequiredFunctionBypassingChatClient decorator,
AgentSession? session,
ChatOptions? options = null)
{
ChatResponse? capturedResponse = null;
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
{
capturedResponse = await decorator.GetResponseAsync(messages, options, ct);
return new AgentResponse(capturedResponse);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
return capturedResponse!;
}
private static Task<ChatResponse> RunWithAgentContextAsync(
NonApprovalRequiredFunctionBypassingChatClient decorator,
AgentSession session)
=> RunWithAgentContextAsync(decorator, session, options: null);
private static async Task RunStreamingWithAgentContextAsync(
NonApprovalRequiredFunctionBypassingChatClient decorator,
AgentSession session,
List<ChatResponseUpdate> updates,
ChatOptions? options = null)
{
var agent = new TestAIAgent
{
RunAsyncFunc = async (messages, agentSession, agentOptions, ct) =>
{
await foreach (var update in decorator.GetStreamingResponseAsync(messages, options, ct))
{
updates.Add(update);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
}
};
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], session);
}
private static IChatClient CreateMockChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetResponse(m, o, ct));
return mock.Object;
}
private static IChatClient CreateMockStreamingChatClient(
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<ChatResponseUpdate>> onGetStreamingResponse)
{
var mock = new Mock<IChatClient>();
mock.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions?>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> m, ChatOptions? o, CancellationToken ct) => onGetStreamingResponse(m, o, ct));
return mock.Object;
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(params ChatResponseUpdate[] updates)
{
foreach (var update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
#endregion
}
@@ -59,15 +59,15 @@ public class ToolApprovalAgentBuilderExtensionsTests
/// Verify that UseToolApproval with custom JsonSerializerOptions works correctly.
/// </summary>
[Fact]
public void UseToolApproval_WithCustomOptions_ReturnsToolApprovalAgent()
public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent()
{
// Arrange
var mockAgent = new Mock<AIAgent>();
var builder = new AIAgentBuilder(mockAgent.Object);
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
var options = new JsonSerializerOptions();
// Act
var result = builder.UseToolApproval(options: options).Build();
var result = builder.UseToolApproval(jsonSerializerOptions: options).Build();
// Assert
Assert.IsType<ToolApprovalAgent>(result);
@@ -47,14 +47,14 @@ public class ToolApprovalAgentTests
}
/// <summary>
/// Verify that constructor accepts custom options.
/// Verify that constructor accepts custom JsonSerializerOptions.
/// </summary>
[Fact]
public void Constructor_CustomOptions_CreatesInstance()
public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync()
{
// Arrange
var innerAgent = new Mock<AIAgent>().Object;
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
var options = new JsonSerializerOptions();
// Act
var agent = new ToolApprovalAgent(innerAgent, options);
@@ -1535,311 +1535,4 @@ public class ToolApprovalAgentTests
}
#endregion
#region Auto-Approval Rules (Heuristics)
/// <summary>
/// Verify that an auto-approval rule can approve a function call that would otherwise need user approval.
/// </summary>
[Fact]
public async Task RunAsync_AutoApprovalRule_ApprovesMatchingToolAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
// Inner agent: first call returns approval request, second returns final response.
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
session);
// Assert — the approval request was auto-approved, inner agent called twice
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
}
/// <summary>
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
/// </summary>
[Fact]
public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "DangerousTool"));
var innerAgent = CreateMockAgent(new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]));
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")] // Only approves ReadTool
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, "Hi")],
session);
// Assert — request surfaced to caller since heuristic doesn't match
var requests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
Assert.Single(requests);
Assert.Equal("DangerousTool", ((FunctionCallContent)requests[0].ToolCall).Name);
}
/// <summary>
/// Verify that multiple auto-approval rules are evaluated in order; first match wins.
/// </summary>
[Fact]
public async Task RunAsync_MultipleAutoApprovalRules_FirstMatchWinsAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "SpecialTool"));
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var rule1Called = false;
var rule2Called = false;
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules =
[
fcc => { rule1Called = true; return new ValueTask<bool>(fcc.Name == "SpecialTool"); },
fcc => { rule2Called = true; return new ValueTask<bool>(true); } // Should not be reached
]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
// Assert — first rule matched, second was never called
Assert.True(rule1Called);
Assert.False(rule2Called);
}
/// <summary>
/// Verify that standing rules are evaluated before auto-approval rules.
/// </summary>
[Fact]
public async Task RunAsync_StandingRuleTakesPrecedenceOverAutoApprovalRuleAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "MyTool"));
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
callCount++;
if (callCount <= 2)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
}
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
});
var heuristicCalled = false;
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => { heuristicCalled = true; return new ValueTask<bool>(true); }]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Call 1: heuristic should be called (no standing rule yet)
var response1 = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")], session);
Assert.True(heuristicCalled);
Assert.Equal("Done", response1.Text);
// Now establish a standing rule by sending AlwaysApprove
heuristicCalled = false;
callCount = 0;
var alwaysApprove = new AlwaysApproveToolApprovalResponseContent(
approvalRequest.CreateResponse(approved: true),
alwaysApproveTool: true,
alwaysApproveToolWithArguments: false);
// Call 2: standing rule should match first, heuristic should NOT be called
var response2 = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
Assert.False(heuristicCalled);
Assert.Equal("Done", response2.Text);
}
/// <summary>
/// Verify that when a batch contains a mix of heuristic-approved and standing-rule-approved
/// requests, the collected approval responses preserve the original request order rather than
/// being grouped by approval kind.
/// </summary>
[Fact]
public async Task RunAsync_MixedAutoApprovals_PreserveOriginalOrderAsync()
{
// Arrange
var session = new ChatClientAgentSession();
// Batch ordering: first request is approved by a heuristic, second by a standing rule.
var heuristicRequest = new ToolApprovalRequestContent("reqA", new FunctionCallContent("callA", "HeuristicTool"));
var standingRequest = new ToolApprovalRequestContent("reqB", new FunctionCallContent("callB", "StandingTool"));
var batchResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, [heuristicRequest, standingRequest])]);
var finalResponse = new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
var callCount = 0;
List<ChatMessage>? secondCallMessages = null;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>((msgs, _, _, _) =>
{
callCount++;
if (callCount == 2)
{
secondCallMessages = msgs.ToList();
}
})
.ReturnsAsync(() => callCount == 1 ? batchResponse : finalResponse);
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "HeuristicTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Establish a standing rule for "StandingTool" via an AlwaysApprove response in the same call.
var alwaysApprove = standingRequest.CreateAlwaysApproveToolResponse("User said always");
// Act — both requests auto-approve (heuristic + standing rule), so the inner agent is re-invoked.
var response = await agent.RunAsync(
[new ChatMessage(ChatRole.User, [alwaysApprove])],
session);
// Assert — inner agent re-called and final response returned.
Assert.Equal(2, callCount);
Assert.Equal("Done", response.Text);
// The injected approval responses must preserve the original request order: reqA before reqB,
// even though reqA was approved by a heuristic and reqB by a standing rule.
Assert.NotNull(secondCallMessages);
var injected = secondCallMessages!
.SelectMany(m => m.Contents)
.OfType<ToolApprovalResponseContent>()
.Where(r => r.RequestId is "reqA" or "reqB")
.ToList();
Assert.Equal(2, injected.Count);
Assert.Equal("reqA", injected[0].RequestId);
Assert.Equal("reqB", injected[1].RequestId);
Assert.All(injected, r => Assert.True(r.Approved));
}
/// <summary>
/// Verify that auto-approval rules work in the streaming path.
/// </summary>
[Fact]
public async Task RunStreamingAsync_AutoApprovalRule_ApprovesMatchingToolAsync()
{
// Arrange
var session = new ChatClientAgentSession();
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
var callCount = 0;
var innerAgent = new Mock<AIAgent>();
innerAgent
.Protected()
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() =>
{
callCount++;
if (callCount == 1)
{
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]);
}
return ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "Done")]);
});
var options = new ToolApprovalAgentOptions
{
AutoApprovalRules = [fcc => new ValueTask<bool>(fcc.Name == "ReadTool")]
};
var agent = new ToolApprovalAgent(innerAgent.Object, options);
// Act
var updates = new List<AgentResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")], session))
{
updates.Add(update);
}
// Assert — the approval request was auto-approved, inner agent streamed twice
Assert.Equal(2, callCount);
Assert.Single(updates);
Assert.Equal("Done", updates[0].Text);
}
#endregion
}
@@ -142,95 +142,6 @@ public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActi
indexName: "CurrentIndex");
}
[Fact]
public async Task ForeachTakeNextWithMultiFieldRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice")),
new KeyValuePair<string, DataValue>("role", new StringDataValue("Engineer"))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithMultiFieldRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
}
/// <summary>
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
/// the loop value must expose the bare scalar, not the single-column wrapper record.
/// </summary>
[Fact]
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
FormulaValue currentValue = this.State.Get(CurrentValueName);
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
Assert.Equal(1m, currentValue.ToObject());
}
/// <summary>
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
/// they are preserved as records so the field name remains accessible inside the loop body.
/// </summary>
[Fact]
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
}
[Fact]
public async Task ForeachTakeLastAsync()
{
@@ -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",
]
@@ -9,7 +9,7 @@ from typing import Any, cast
from ag_ui.core import BaseEvent
from agent_framework import SupportsAgentRun
from ._agent_run import PendingApprovalEntry, run_agent_stream
from ._agent_run import run_agent_stream
class AgentConfig:
@@ -107,7 +107,7 @@ class AgentFrameworkAgent:
# Populated when approval requests are emitted; consumed when responses arrive.
# Prevents bypass, function name spoofing, and replay attacks.
# Bounded to prevent unbounded growth from abandoned approval requests.
self._pending_approvals: OrderedDict[str, PendingApprovalEntry] = OrderedDict()
self._pending_approvals: OrderedDict[str, str] = OrderedDict()
self._pending_approvals_max_size: int = 10_000
async def run(
@@ -8,7 +8,7 @@ import json
import logging
import uuid
from collections.abc import AsyncIterable, Awaitable
from typing import TYPE_CHECKING, Any, TypedDict, cast
from typing import TYPE_CHECKING, Any, cast
from ag_ui.core import (
BaseEvent,
@@ -56,7 +56,6 @@ from ._run_common import (
_stringify_tool_result, # type: ignore
)
from ._utils import (
canonical_function_arguments,
convert_agui_tools_to_agent_framework,
generate_event_id,
get_conversation_id_from_update,
@@ -408,33 +407,7 @@ def _make_approval_tool_result_events(resolved_approval_results: list[Content])
return events
class _PendingApproval(TypedDict):
"""Pending approval details for a requested function call."""
name: str
arguments: str | None
PendingApprovalEntry = _PendingApproval | str
def _make_pending_approval_entry(name: str, arguments: str | None) -> _PendingApproval:
return {"name": name, "arguments": arguments}
def _pending_approval_name(entry: PendingApprovalEntry) -> str | None:
if isinstance(entry, str):
return entry
return entry["name"]
def _pending_approval_arguments(entry: PendingApprovalEntry) -> str | None:
if isinstance(entry, str):
return None
return entry["arguments"]
def _evict_oldest_approvals(registry: dict[str, PendingApprovalEntry], max_size: int = 10_000) -> None:
def _evict_oldest_approvals(registry: dict[str, str], max_size: int = 10_000) -> None:
"""Evict the oldest entries from the pending-approvals registry (LRU).
Only effective when *registry* is an ``OrderedDict``; plain dicts are
@@ -454,7 +427,7 @@ async def _resolve_approval_responses(
tools: list[Any],
agent: SupportsAgentRun,
run_kwargs: dict[str, Any],
pending_approvals: dict[str, PendingApprovalEntry] | None = None,
pending_approvals: dict[str, str] | None = None,
thread_id: str = "",
) -> list[Content]:
"""Execute approved function calls and replace approval content with results.
@@ -507,8 +480,7 @@ async def _resolve_approval_responses(
invalid_ids.add(resp_id)
continue
pending_entry = pending_approvals[registry_key]
pending_name = _pending_approval_name(pending_entry)
pending_name = pending_approvals[registry_key]
if resp_name != pending_name:
logger.warning(
"Rejected approval response id=%s: function name mismatch (response=%s, pending=%s)",
@@ -519,16 +491,6 @@ async def _resolve_approval_responses(
invalid_ids.add(resp_id)
continue
pending_arguments = _pending_approval_arguments(pending_entry)
response_arguments = canonical_function_arguments(resp.function_call)
if pending_arguments is not None and response_arguments != pending_arguments:
logger.warning(
"Rejected approval response id=%s: function arguments mismatch",
resp_id,
)
invalid_ids.add(resp_id)
continue
# Valid — consume entry to prevent replay
del pending_approvals[registry_key]
if resp.approved:
@@ -752,7 +714,7 @@ async def run_agent_stream(
input_data: dict[str, Any],
agent: SupportsAgentRun,
config: AgentConfig,
pending_approvals: dict[str, PendingApprovalEntry] | None = None,
pending_approvals: dict[str, str] | None = None,
) -> AsyncGenerator[BaseEvent]:
"""Run agent and yield AG-UI events.
@@ -955,10 +917,7 @@ async def run_agent_stream(
# Register pending approval requests so we can validate responses later
if content_type == "function_approval_request" and pending_approvals is not None:
if content.id and content.function_call and content.function_call.name:
pending_approvals[f"{thread_id}:{content.id}"] = _make_pending_approval_entry(
content.function_call.name,
canonical_function_arguments(content.function_call),
)
pending_approvals[f"{thread_id}:{content.id}"] = content.function_call.name
# Evict oldest entries if the registry exceeds a safe bound (LRU)
_evict_oldest_approvals(pending_approvals, max_size=10_000)
else:
@@ -56,22 +56,6 @@ def safe_json_parse(value: Any) -> dict[str, Any] | None:
return None
def canonical_function_arguments(function_call: Any) -> str | None:
"""Return a stable representation of function-call arguments."""
if function_call is None:
return None
try:
parsed_arguments = function_call.parse_arguments()
except Exception:
parsed_arguments = getattr(function_call, "arguments", None)
if parsed_arguments is None:
parsed_arguments = {}
return json.dumps(make_json_safe(parsed_arguments), sort_keys=True, separators=(",", ":"))
def get_role_value(message: Any) -> str:
"""Extract role string from a message object.
@@ -35,7 +35,7 @@ from ._run_common import (
_extract_resume_payload,
_normalize_resume_interrupts,
)
from ._utils import canonical_function_arguments, generate_event_id, make_json_safe
from ._utils import generate_event_id, make_json_safe
logger = logging.getLogger(__name__)
@@ -324,29 +324,6 @@ def _coerce_response_for_request(request_event: Any, value: Any) -> Any | None:
return candidate
def _approval_response_matches_request(request_id: str, request_event: Any, response: Any) -> bool:
"""Check whether an approval response matches the pending approval request."""
request_data = getattr(request_event, "data", None)
if not isinstance(request_data, Content) or request_data.type != "function_approval_request":
return True
if not isinstance(response, Content) or response.type != "function_approval_response":
return False
if str(getattr(response, "id", "")) != request_id:
return False
request_call = getattr(request_data, "function_call", None)
response_call = getattr(response, "function_call", None)
if request_call is None or response_call is None:
return False
if getattr(response_call, "name", None) != getattr(request_call, "name", None):
return False
return canonical_function_arguments(response_call) == canonical_function_arguments(request_call)
def _single_pending_response_from_value(pending_events: dict[str, Any], value: Any) -> dict[str, Any]:
"""Map a scalar resume payload to the single pending request (if unambiguous)."""
if value is None or len(pending_events) != 1:
@@ -366,13 +343,6 @@ def _single_pending_response_from_value(pending_events: dict[str, Any], value: A
)
return {}
if not _approval_response_matches_request(str(request_id), request_event, coerced_value):
logger.info(
"Ignoring pending request response for request_id=%s: approval response does not match pending request",
request_id,
)
return {}
return {str(request_id): coerced_value}
@@ -402,12 +372,6 @@ def _coerce_responses_for_pending_requests(
_response_type_name(request_event),
)
continue
if not _approval_response_matches_request(request_key, request_event, coerced_value):
logger.info(
"Ignoring resume response for request_id=%s: approval response does not match pending request",
request_key,
)
continue
normalized[request_key] = coerced_value
return normalized
@@ -1407,92 +1407,6 @@ async def test_fabricated_rejection_without_pending_approval_is_blocked(streamin
assert False, "Fabricated rejection response leaked as function_result into LLM messages"
async def test_approval_argument_mismatch_is_blocked(streaming_chat_client_stub):
"""An approval response must not execute changed arguments for the pending call."""
from agent_framework import tool
from agent_framework.ag_ui import AgentFrameworkAgent
executed_args: list[dict[str, Any]] = []
@tool(
name="update_record",
description="Update a record",
approval_mode="always_require",
)
def update_record(record_id: str, value: str) -> str:
executed_args.append({"record_id": record_id, "value": value})
return f"updated {record_id} to {value}"
async def stream_fn_approval(
messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(
contents=[
Content.from_function_call(
name="update_record",
call_id="call_update_001",
arguments={"record_id": "alpha", "value": "approved"},
)
]
)
wrapper = AgentFrameworkAgent(
agent=Agent(
client=streaming_chat_client_stub(stream_fn_approval),
name="test_agent",
instructions="Test",
tools=[update_record],
)
)
thread_id = "thread-argument-mismatch-test"
events1: list[Any] = []
async for event in wrapper.run({"thread_id": thread_id, "messages": [{"role": "user", "content": "update"}]}):
events1.append(event)
assert any("call_update_001" in k for k in wrapper._pending_approvals)
async def stream_fn_post(
messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[Content.from_text(text="Done")])
wrapper.agent = Agent(
client=streaming_chat_client_stub(stream_fn_post),
name="test_agent",
instructions="Test",
tools=[update_record],
)
turn2_input: dict[str, Any] = {
"thread_id": thread_id,
"messages": [
{
"role": "user",
"content": "approve",
"function_approvals": [
{
"id": "call_update_001",
"call_id": "call_update_001",
"name": "update_record",
"approved": True,
"arguments": {"record_id": "beta", "value": "changed"},
}
],
},
],
}
events2: list[Any] = []
async for event in wrapper.run(turn2_input):
events2.append(event)
assert executed_args == []
assert any("call_update_001" in k for k in wrapper._pending_approvals), (
"Pending approval should be preserved after argument mismatch for legitimate retry"
)
async def test_state_update_end_to_end_via_real_tool_invocation(streaming_chat_client_stub):
"""End-to-end coverage for issue #3167: a real ``@tool`` returning ``state_update`` must
emit a deterministic STATE_SNAPSHOT through the full pipeline.
@@ -1352,70 +1352,6 @@ async def test_workflow_run_approval_via_messages_approved() -> None:
assert not resumed_finished.get("interrupt")
async def test_workflow_run_approval_argument_mismatch_keeps_interrupt_pending() -> None:
"""Workflow approval responses must not resume with changed function arguments."""
handled_responses: list[dict[str, Any]] = []
class ApprovalExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="approval_executor")
@handler
async def start(self, message: Any, ctx: WorkflowContext) -> None:
del message
function_call = Content.from_function_call(
call_id="refund-call",
name="submit_refund",
arguments={"order_id": "12345", "amount": "$89.99"},
)
approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call)
await ctx.request_info(approval_request, Content, request_id="approval-1")
@response_handler
async def handle_approval(self, original_request: Content, response: Content, ctx: WorkflowContext) -> None:
del original_request
if response.function_call is not None:
handled_responses.append(response.function_call.parse_arguments() or {})
await ctx.yield_output("handled")
workflow = WorkflowBuilder(start_executor=ApprovalExecutor()).build()
first_events = [
event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)
]
first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0].model_dump()
interrupt_payload = cast(list[dict[str, Any]], first_finished.get("interrupt"))
assert isinstance(interrupt_payload, list) and len(interrupt_payload) == 1
resumed_events = [
event
async for event in run_workflow_stream(
{
"messages": [
{
"role": "user",
"content": "",
"function_approvals": [
{
"approved": True,
"id": "approval-1",
"call_id": "refund-call",
"name": "submit_refund",
"arguments": {"order_id": "99999", "amount": "$1000.00"},
}
],
}
],
},
workflow,
)
]
assert handled_responses == []
resumed_finished = [event for event in resumed_events if event.type == "RUN_FINISHED"][0].model_dump()
assert resumed_finished.get("interrupt")
async def test_workflow_run_approval_via_messages_denied() -> None:
"""Denied approval response sent via messages (function_approvals) should satisfy the pending request."""
+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",
]
@@ -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 -14
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`)
@@ -76,19 +76,6 @@ agent_framework/
- **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner.
- **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts.
### Model Context Protocol (`_mcp.py`)
- **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s.
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
- `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel.
- `max_task_wait: timedelta | None` — client-side deadline for the whole post-create lifecycle (poll + result fetch). When exceeded, raises `ToolExecutionException` and fires a best-effort `tasks/cancel`. `None` (default) means no client-side bound. Bounds sleeps, sends, AND reconnects via `asyncio.wait_for`.
- **Permissive fallback**: servers that ignore the augmentation (return `CallToolResult` directly) or reject the unknown `task` field with `METHOD_NOT_FOUND` / `INVALID_PARAMS` fall back to the plain `session.call_tool(...)` path so legacy servers keep working. An unparseable success response (server accepted the augmented call but returned a payload that is neither `CreateTaskResult` nor `CallToolResult`) **does not** fall back — it raises `ToolExecutionException` to avoid double-executing a side-effecting tool.
- **Submit-vs-track reconnect policy**: a dropped connection before a `task_id` is known raises `ToolExecutionException("connection lost; task state unknown")` without re-issuing the augmented `tools/call`, so a server that accepted the request but lost the response cannot be made to start the same operation twice; once a `task_id` exists, `tasks/get` / `tasks/result` reconnect once and retry against the same id (a shared `_send_with_one_reconnect` helper).
- **Cancel-on-abandonment vs terminal failure**: any path where the remote task may still be running (max-wait exceeded, hard `McpError` in poll, malformed `tasks/get`, second connection loss in poll/fetch, reconnect failure) fires best-effort `tasks/cancel` before raising. Terminal failures (`failed`/`cancelled`/`input_required` server-side, `completed+isError`, malformed `tasks/result` after server completed) do **not** cancel — the server is already done. `_MCPTaskAbandoned` is the private marker distinguishing the two.
- **Transient poll retry**: a slow `tasks/get` that surfaces as `McpError(code=408 REQUEST_TIMEOUT)` is retried (bounded by `max_task_wait`). All other non-connection `McpError`s during poll are treated as abandonment. `tasks/result` does not get transient retry — the server has already completed, so a slow payload fetch is anomalous.
### File Access Harness (`_harness/_file_access.py`)
- **`AgentFileStore`** - Abstract async store backing the file-access harness. Implementations expose `write_file`, `read_file`, `delete_file`, `list_files`, `file_exists`, `search_files`, and `create_directory` over forward-slash relative paths.
@@ -124,7 +124,7 @@ from ._harness._todo import (
TodoSessionStore,
TodoStore,
)
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
from ._middleware import (
AgentContext,
AgentMiddleware,
@@ -168,9 +168,6 @@ from ._skills import (
InlineSkillResource,
InlineSkillScript,
InMemorySkillsSource,
MCPSkill,
MCPSkillResource,
MCPSkillsSource,
Skill,
SkillFrontmatter,
SkillResource,
@@ -444,12 +441,8 @@ __all__ = [
"InlineSkillResource",
"InlineSkillScript",
"LocalEvaluator",
"MCPSkill",
"MCPSkillResource",
"MCPSkillsSource",
"MCPStdioTool",
"MCPStreamableHTTPTool",
"MCPTaskOptions",
"MCPWebsocketTool",
"MemoryContextProvider",
"MemoryFileStore",
+29 -80
View File
@@ -92,16 +92,12 @@ OptionsCoT = TypeVar(
def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Merge two options dicts, with override values taking precedence.
``None`` is treated as "unset": ``None`` overrides are skipped so they don't clobber a base
value, and the merged result is stripped of any remaining ``None`` values in a final pass so
unset options are never forwarded (e.g. an unset ``store`` is left for the service to default).
Args:
base: The base options dict.
override: The override options dict (values take precedence).
Returns:
A new merged options dict containing no ``None`` values.
A new merged options dict.
"""
result = dict(base)
@@ -127,7 +123,7 @@ def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str,
result["instructions"] = f"{result['instructions']}\n{value}"
else:
result[key] = value
return {key: value for key, value in result.items() if value is not None}
return result
def _sanitize_agent_name(agent_name: str | None) -> str | None:
@@ -464,9 +460,6 @@ class BaseAgent(SerializationMixin):
if provider_session is None and self.context_providers:
provider_session = AgentSession()
# When per-service-call persistence is enabled, the per-service-call middleware owns
# HistoryProvider persistence (in both the local and service-managed cases), so skip
# them on the once-per-run path to avoid double persistence.
per_service_call_history_required = self.require_per_service_call_history_persistence and any(
isinstance(provider, HistoryProvider) for provider in self.context_providers
)
@@ -693,16 +686,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
description: A brief description of the agent's purpose.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
require_per_service_call_history_persistence: When True (and a HistoryProvider is
present), the provider always persists history via per-service-call middleware,
regardless of whether the client stores history server-side. If the client does
not store history, the middleware also loads providers around each model call and
drives the function loop with a local conversation; if it does, loading is skipped
(the service-managed conversation is the source of truth) and the middleware only
persists. A warning is logged for providers with ``load_messages=True`` when
loading is skipped because service-side storage is active. When no HistoryProvider
is present, this flag has no effect (no middleware is installed and nothing is
persisted).
require_per_service_call_history_persistence: When True, history providers are invoked
around each model call instead of once per ``run()`` when the service
is not already storing history. If service-side storage is active for
the run, the agent skips local history providers and relies on the
service-managed conversation instead.
default_options: A TypedDict containing chat options. When using a typed agent like
``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for
provider-specific options including temperature, max_tokens, model,
@@ -803,20 +791,22 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
self,
*,
session: AgentSession | None,
conversation_id: str | None,
options: Mapping[str, Any] | None,
service_stores_history: bool,
) -> list[HistoryProvider]:
history_providers = self._get_history_providers()
if not self.require_per_service_call_history_persistence or not history_providers:
return []
# A live service-managed session id takes precedence over the resolved conversation id.
if session and session.service_session_id:
conversation_id = session.service_session_id
# Without service-side storage the middleware persists locally and drives the function
# loop with a local sentinel, which cannot be reconciled with an existing service-managed
# conversation. When the service stores history, an existing conversation id is expected.
if conversation_id is not None and not service_stores_history:
conversation_id = (
session.service_session_id
if session and session.service_session_id
else cast(str | None, (options or {}).get("conversation_id") or self.default_options.get("conversation_id"))
)
if service_stores_history:
return []
if conversation_id is not None:
raise AgentInvalidRequestException(
"require_per_service_call_history_persistence cannot be used "
"with an existing service-managed conversation."
@@ -1177,34 +1167,18 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
input_messages = normalize_messages(messages)
# Combine agent-level defaults with runtime options up front so the decisions below read
# `store` from a single place rather than introspecting both dicts. _merge_options applies
# the same precedence used for the actual client call (runtime wins; unset/None falls back
# to the agent default).
effective_options = _merge_options(self.default_options, opts)
# `store` in runtime or agent options takes precedence over the client's default
# storage behavior. An explicit `store=False` forces local (in-memory) history
# injection even when the client stores server-side by default; an explicit
# `store=True` forces service-side storage. A `store=None`/unset value means the
# service falls back to its own default.
explicit_store = effective_options.get("store")
# Internal behavior hint: will the service own history for this run? Only when the
# user left `store` unset do we fall back to the client's STORES_BY_DEFAULT.
service_stores_history = (
explicit_store if explicit_store is not None else getattr(self.client, "STORES_BY_DEFAULT", False)
)
# Resolve conversation_id from the same combined view so an agent-level default is honored
# when the runtime omits it (a live session id still takes precedence below).
effective_conversation_id = effective_options.get("conversation_id")
# `store` in runtime or agent options takes precedence over client-level storage
# indicators. An explicit `store=False` forces local (in-memory) history injection,
# even if the client is configured to use service-side storage by default.
store_ = opts.get("store", self.default_options.get("store", getattr(self.client, "STORES_BY_DEFAULT", False)))
# Auto-inject InMemoryHistoryProvider when session is provided, no context providers
# registered, and no service-side storage indicators
if (
session is not None
and not self.context_providers
and not session.service_session_id
and not effective_conversation_id
and not service_stores_history
and not opts.get("conversation_id")
and not store_
):
self.context_providers.append(InMemoryHistoryProvider())
@@ -1214,30 +1188,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
per_service_call_history_providers = self._resolve_per_service_call_history_providers(
session=active_session,
conversation_id=effective_conversation_id,
service_stores_history=service_stores_history,
options=opts,
service_stores_history=bool(store_),
)
# When require_per_service_call_history_persistence is set together with a
# HistoryProvider, the per-service-call middleware (installed below) always persists
# the provider. ``service_stores_history`` only selects how the middleware behaves:
# - service does not store: the middleware also loads providers and drives the function
# loop with a local sentinel conversation id, or
# - service stores: the middleware skips loading (the service owns history) and simply
# persists each service call while the real conversation id flows through.
# In the service-managed case loading is skipped, so warn for providers that expect to load.
history_providers = self._get_history_providers()
if self.require_per_service_call_history_persistence and history_providers and service_stores_history:
for provider in history_providers:
if provider.load_messages:
logger.warning(
"HistoryProvider '%s' has load_messages=True but the chat client stores history "
"server-side; skipping local history load and relying on the service-managed "
"conversation. Set store=False to load from the provider, or load_messages=False "
"to silence this warning.",
provider.source_id,
)
session_context, chat_options = await self._prepare_session_and_messages(
session=active_session,
input_messages=input_messages,
@@ -1311,8 +1265,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
}
if model is not None:
run_opts["model"] = model
# _merge_options strips unset (None) options, so e.g. an unset `store` is not forwarded
# and the service decides its own default.
# Remove None values and merge with chat_options
run_opts = {k: v for k, v in run_opts.items() if v is not None}
co = _merge_options(chat_options, run_opts)
# Build session_messages from session context: context messages + input messages
@@ -1326,7 +1280,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
agent=self,
session=active_session,
providers=per_service_call_history_providers,
service_stores_history=service_stores_history,
)
existing_middleware = effective_client_kwargs.get("middleware")
if isinstance(existing_middleware, Sequence) and not isinstance(existing_middleware, (str, bytes)):
@@ -1366,7 +1319,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
"input_messages": input_messages,
"session_messages": session_messages,
"agent_name": agent_name,
"suppress_response_id": bool(per_service_call_history_providers) and not service_stores_history,
"suppress_response_id": bool(per_service_call_history_providers),
"chat_options": co,
"compaction_strategy": compaction_strategy or self.compaction_strategy,
"tokenizer": tokenizer or self.tokenizer,
@@ -1460,15 +1413,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
options=options or {},
)
# When per-service-call persistence is enabled, the per-service-call middleware owns
# HistoryProvider loading (it loads locally when the service does not store history, or
# relies on the service when it does), so skip them on the once-per-run before_run path.
per_service_call_history_required = self.require_per_service_call_history_persistence and bool(
self._get_history_providers()
)
# Run before_run providers (forward order, skip HistoryProvider when per-service-call
# persistence owns loading)
# Run before_run providers (forward order, skip HistoryProvider when per-service-call persistence owns history)
for provider in self.context_providers:
if per_service_call_history_required and isinstance(provider, HistoryProvider):
continue
@@ -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,
)
@@ -604,13 +597,10 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
and dict literals are accepted without specialized option typing.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
require_per_service_call_history_persistence: When enabled (and a HistoryProvider is
present), the provider always persists history after each model call. If the
client does not store history server-side, history providers are also loaded and
injected around each model call; if it does, provider loading is skipped and the
service-managed conversation is the source of truth (persistence still happens
after each model call). When no HistoryProvider is present, this flag has no
effect (no middleware is installed and nothing is persisted).
require_per_service_call_history_persistence: Whether to require per-service-call
chat history persistence. When enabled, history providers are invoked around
each model call instead of once per ``run()`` when the service is not already
storing history.
function_invocation_configuration: Optional function invocation configuration override.
compaction_strategy: Optional agent-level compaction override. When omitted,
client-level compaction defaults remain in effect for each call.
@@ -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,9 +58,6 @@ class ExperimentalFeature(str, Enum):
FOUNDRY_PREVIEW_TOOLS = "FOUNDRY_PREVIEW_TOOLS"
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
HARNESS = "HARNESS"
MCP_LONG_RUNNING_TASKS = "MCP_LONG_RUNNING_TASKS"
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."""
File diff suppressed because it is too large Load Diff
@@ -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:
@@ -16,7 +16,6 @@ from __future__ import annotations
import asyncio
import copy
import json
import logging
import threading
import uuid
import weakref
@@ -37,8 +36,6 @@ if TYPE_CHECKING:
from ._middleware import MiddlewareTypes
logger = logging.getLogger("agent_framework")
# Registry of known types for state deserialization
_STATE_TYPE_REGISTRY: dict[str, type] = {}
@@ -583,7 +580,6 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
agent: SupportsAgentRun,
session: AgentSession,
providers: Sequence[HistoryProvider],
service_stores_history: bool = False,
) -> None:
"""Initialize the middleware.
@@ -591,16 +587,10 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
agent: The agent that owns the history providers.
session: The active session for the current run.
providers: The history providers participating in per-service-call persistence.
service_stores_history: When True, the chat client stores history server-side. The
middleware then skips loading providers and leaves the real conversation id
untouched, persisting each service call without driving the function loop with a
local sentinel. When False, the middleware loads providers and uses a local
sentinel conversation id so the function loop runs without service-side storage.
"""
self._agent = agent
self._session = session
self._providers = list(providers)
self._service_stores_history = service_stores_history
async def _prepare_service_call_context(self, messages: Sequence[Message]) -> SessionContext:
"""Create a per-call SessionContext and load history providers into it."""
@@ -612,9 +602,6 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
)
for source_id, source_messages in context_messages.items():
service_call_context.extend_messages(source_id, source_messages)
# When the service stores history, it owns loading; the providers are write-only sinks.
if self._service_stores_history:
return service_call_context
for provider in self._providers:
if not provider.load_messages:
continue
@@ -665,35 +652,17 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
response: ChatResponse,
) -> ChatResponse:
"""Persist a model response and apply the local follow-up sentinel when needed."""
if (
not self._service_stores_history
and response.conversation_id is not None
and not is_local_history_conversation_id(response.conversation_id)
):
if response.conversation_id is not None and not is_local_history_conversation_id(response.conversation_id):
raise ChatClientInvalidResponseException(
"require_per_service_call_history_persistence cannot be used "
"when the chat client returns a real conversation_id."
)
# In storing mode the service is expected to echo a conversation id that the next run
# resumes from. If it comes back empty, the provider still captures this turn but there is
# no service id to load from next time, so cross-turn history can be lost silently. Warn
# every time so this uncommon, easy-to-miss failure mode cannot fail quietly.
if self._service_stores_history and response.conversation_id is None:
logger.warning(
"require_per_service_call_history_persistence is enabled with a chat client that "
"stores history server-side, but the client returned no conversation_id; cross-turn "
"history may not resume. Set store=False to load and resume from the HistoryProvider "
"instead."
)
await self._persist_service_call_response(
service_call_context=service_call_context,
response=response,
)
# The local sentinel only applies when the service does not store history; when it does,
# the real conversation id already drives function-loop continuation.
if not self._service_stores_history and _response_contains_follow_up_request(response):
if _response_contains_follow_up_request(response):
response.mark_internal_conversation_id()
response.conversation_id = LOCAL_HISTORY_CONVERSATION_ID
return response
@@ -712,12 +681,8 @@ class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
result type for streaming or non-streaming execution.
"""
service_call_context = await self._prepare_service_call_context(context.messages)
# When the service stores history, leave the outgoing messages and the real conversation
# id untouched (pass-through); the middleware only persists. Otherwise reconstruct the
# outgoing messages from the loaded local history and strip the local sentinel.
if not self._service_stores_history:
context.messages = service_call_context.get_messages(include_input=True)
self._strip_local_conversation_id(context)
context.messages = service_call_context.get_messages(include_input=True)
self._strip_local_conversation_id(context)
await call_next()
@@ -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]:
@@ -80,7 +80,6 @@ __all__ = [
"EmbeddingTelemetryLayer",
"OtelAttr",
"configure_otel_providers",
"create_mcp_client_span",
"create_metric_views",
"create_resource",
"disable_instrumentation",
@@ -88,7 +87,6 @@ __all__ = [
"enable_sensitive_telemetry",
"get_meter",
"get_tracer",
"set_mcp_span_error",
]
@@ -112,6 +110,7 @@ INNER_ACCUMULATED_USAGE: Final[contextvars.ContextVar[UsageDetails | None]] = co
"inner_accumulated_usage", default=None
)
OTEL_METRICS: Final[str] = "__otel_metrics__"
TOKEN_USAGE_BUCKET_BOUNDARIES: Final[tuple[float, ...]] = (
1,
@@ -293,14 +292,6 @@ class OtelAttr(str, Enum):
AGENT_CREATE_OPERATION = "create_agent"
AGENT_INVOKE_OPERATION = "invoke_agent"
# MCP attributes (https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/)
MCP_METHOD_NAME = "mcp.method.name"
MCP_PROTOCOL_VERSION = "mcp.protocol.version"
MCP_SESSION_ID = "mcp.session.id"
PROMPT_NAME = "gen_ai.prompt.name"
NETWORK_TRANSPORT = "network.transport"
NETWORK_PROTOCOL_NAME = "network.protocol.name"
# Agent Framework specific attributes
MEASUREMENT_FUNCTION_TAG_NAME = "agent_framework.function.name"
MEASUREMENT_FUNCTION_INVOCATION_DURATION = "agent_framework.function.invocation.duration"
@@ -2022,61 +2013,6 @@ def get_function_span(
)
# region MCP span helpers
@contextlib.contextmanager
def create_mcp_client_span(
method_name: str,
target: str | None = None,
attributes: dict[str, Any] | None = None,
) -> Generator[trace.Span, Any, Any]:
"""Create an MCP client span per OTel MCP semantic conventions.
Span name follows the format ``{mcp.method.name} {target}`` when a target
is available, otherwise just ``{mcp.method.name}``.
See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#client
Args:
method_name: The MCP method name (e.g. ``initialize``, ``tools/call``).
target: Optional low-cardinality target (tool name, prompt name).
attributes: Additional span attributes.
"""
span_name = f"{method_name} {target}" if target else method_name
attrs: dict[str, Any] = {OtelAttr.MCP_METHOD_NAME: method_name}
if attributes:
attrs.update(attributes)
tracer = get_tracer() if OBSERVABILITY_SETTINGS.ENABLED else trace.NoOpTracer()
span = tracer.start_span(span_name, kind=trace.SpanKind.CLIENT, attributes=attrs)
with trace.use_span(
span=span,
end_on_exit=True,
record_exception=True,
set_status_on_exception=True,
) as current_span:
yield current_span
def set_mcp_span_error(
span: trace.Span,
error_type: str,
description: str | None = None,
) -> None:
"""Set error status and ``error.type`` on an MCP span.
Args:
span: The span to mark as errored.
error_type: The error type string (e.g. ``tool_error``, exception class name).
description: Optional description (e.g. JSON-RPC error message).
"""
span.set_attribute(OtelAttr.ERROR_TYPE, error_type)
span.set_status(trace.StatusCode.ERROR, description=description)
# endregion
@contextlib.contextmanager
def _activate_span(span: trace.Span) -> Generator[None]:
"""Attach ``span`` as the current span in the OpenTelemetry context.
+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"
+2 -472
View File
@@ -3,7 +3,6 @@
import contextlib
import inspect
import json
import logging
from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
@@ -43,8 +42,6 @@ from agent_framework._mcp import MCPTool, _build_prefixed_mcp_name, _normalize_m
from agent_framework._middleware import FunctionInvocationContext
from agent_framework.exceptions import AgentInvalidRequestException, ChatClientInvalidResponseException
from .conftest import MockBaseChatClient
class _FixedTokenizer:
def __init__(self, token_count: int) -> None:
@@ -612,7 +609,6 @@ async def test_streaming_per_service_call_persistence_hides_response_id_from_aft
async def test_per_service_call_persistence_uses_real_service_storage_when_client_stores_by_default(
chat_client_base: SupportsChatGetResponse,
caplog: pytest.LogCaptureFixture,
) -> None:
provider = _RecordingHistoryProvider()
@@ -653,22 +649,15 @@ async def test_per_service_call_persistence_uses_real_service_storage_when_clien
require_per_service_call_history_persistence=True,
)
with caplog.at_level(logging.WARNING, logger="agent_framework"):
result = await agent.run("What's the weather in Seattle?", session=session)
result = await agent.run("What's the weather in Seattle?", session=session)
provider_state = session.state[provider.source_id]
assert result.text == "It is sunny in Seattle."
assert result.response_id == "resp_call_2"
assert chat_client_base.call_count == 2
# The service owns the conversation, so the provider never loads (issue #5798).
assert "get_call_count" not in provider_state
# Persistence is owned by the per-service-call middleware: it persists once per service call
# (issue #5798: the provider must never be silently bypassed when the service stores history).
# This run makes two service calls (function call + final answer), so it persists twice.
assert provider_state["save_call_count"] == 2
# load_messages=True while the service stores history surfaces a warning.
assert any("load_messages" in record.message for record in caplog.records)
assert "save_call_count" not in provider_state
assert session.service_session_id == "resp_service_managed"
@@ -2007,19 +1996,6 @@ def test_merge_options_none_values_ignored():
assert result["key2"] == "value2"
def test_merge_options_drops_none_base_values():
"""Test _merge_options strips None values so unset options are never forwarded."""
base = {"store": None, "temperature": 0.5}
override = {"top_p": 0.9}
result = _merge_options(base, override)
# An unset base value (e.g. store=None from default_options) must not survive the merge.
assert "store" not in result
assert result["temperature"] == 0.5
assert result["top_p"] == 0.9
def test_merge_options_runtime_model_overrides_default_model() -> None:
"""Test _merge_options lets a runtime model override a default model."""
result = _merge_options({"model": "default-model"}, {"model": "runtime-model"})
@@ -2682,449 +2658,3 @@ async def test_as_tool_raises_on_user_input_request(client: SupportsChatGetRespo
assert len(exc_info.value.contents) == 1
assert exc_info.value.contents[0].type == "oauth_consent_request"
assert exc_info.value.contents[0].consent_link == "https://login.microsoftonline.com/consent"
# region Per-service-call history persistence scenario matrix
#
# The driving field is ``require_per_service_call_history_persistence``. Every scenario runs a
# single agent run that makes **two service calls** -- a function call followed by a final
# completion -- so the *timing* of persistence is observable:
#
# * When the flag is ``True``, the per-service-call middleware persists the provider **after each
# service call**. So the function-call turn is already saved by the time the second (final)
# service call starts. This holds regardless of whether the chat client stores history
# server-side (the bug in issue #5798 was that a storing client silently bypassed persistence).
# * When the flag is ``False``, the provider persists **once, at the end of the run** -- nothing is
# saved between the two service calls.
#
# ``SpyChatClient.saves_before_call`` records ``provider.save_calls`` at the start of every service
# call, so ``[0, 1]`` means "the function-call turn was persisted before the final call" and
# ``[0, 0]`` means "no persistence happened mid-run". The client's ``store`` / ``STORES_BY_DEFAULT``
# only selects *how* the middleware behaves -- never *whether* the provider persists.
_PSC_SERVICE_CONVERSATION_ID = "svc-conversation"
_psc_stream_params = pytest.mark.parametrize("stream", [False, True], ids=["sync", "stream"])
@tool(name="lookup_weather", approval_mode="never_require")
def _psc_lookup_weather(location: str) -> str:
return f"Weather in {location}: sunny"
def _psc_function_call_script() -> list[tuple[str, ...]]:
"""A fresh function-call-then-final-completion script (the client mutates it)."""
return [
("call", "call_1", "lookup_weather", '{"location": "Seattle"}'),
("text", "It is sunny in Seattle."),
]
class _PscSpyHistoryProvider(HistoryProvider):
"""In-memory history provider that records load/save calls for assertions."""
def __init__(self, source_id: str = "spy_history", **kwargs: Any) -> None:
super().__init__(source_id, **kwargs)
self._messages: list[Message] = []
self.get_calls: int = 0
self.save_calls: int = 0
self.saved_batches: list[list[Message]] = []
async def get_messages(
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
) -> list[Message]:
self.get_calls += 1
return list(self._messages)
async def save_messages(
self,
session_id: str | None,
messages: Sequence[Message],
*,
state: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
self.save_calls += 1
self.saved_batches.append(list(messages))
self._messages.extend(messages)
@property
def stored_messages(self) -> list[Message]:
return list(self._messages)
class _PscSpyChatClient(MockBaseChatClient):
"""Chat client that scripts a function-call/final-completion sequence.
It records, at the start of each service call, how many provider saves have already happened
(``saves_before_call``), what messages it received, and what options it saw. When the effective
``store`` is truthy it returns a stable ``conversation_id`` to mimic a server-managed
conversation, so the framework propagates ``session.service_session_id``.
"""
def __init__(
self,
*,
provider: _PscSpyHistoryProvider,
stores_by_default: bool = False,
script: list[tuple[str, ...]] | None = None,
echo_conversation_id: bool = True,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.STORES_BY_DEFAULT = stores_by_default # type: ignore[attr-defined]
self._provider = provider
self._script = list(script) if script is not None else [("text", "ok")]
self._echo_conversation_id = echo_conversation_id
self.received_messages: list[list[Message]] = []
self.received_options: list[dict[str, Any]] = []
self.saves_before_call: list[int] = []
def _effective_store(self, options: dict[str, Any]) -> bool:
store = options.get("store")
if store is None:
return bool(self.STORES_BY_DEFAULT)
return bool(store)
def _next_contents(self) -> list[Content]:
turn = self._script.pop(0) if self._script else ("text", "ok")
if turn[0] == "call":
_, call_id, name, args = turn
return [Content.from_function_call(call_id=call_id, name=name, arguments=args)]
return [Content.from_text(turn[1])]
def _inner_get_response( # type: ignore[override]
self,
*,
messages: MutableSequence[Message],
stream: bool,
options: dict[str, Any],
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
self.received_messages.append(list(messages))
self.received_options.append(dict(options))
self.saves_before_call.append(self._provider.save_calls)
store_and_echo = self._effective_store(options) and self._echo_conversation_id
conv_id = _PSC_SERVICE_CONVERSATION_ID if store_and_echo else None
contents = self._next_contents()
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
self.call_count += 1
yield ChatResponseUpdate(
contents=contents,
role="assistant",
finish_reason="stop",
conversation_id=conv_id,
)
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
response = ChatResponse.from_updates(updates, output_format_type=options.get("response_format"))
if conv_id:
response.conversation_id = conv_id
return response
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
self.call_count += 1
return ChatResponse(
messages=Message(role="assistant", contents=contents),
conversation_id=conv_id,
)
return _get()
def _psc_build_agent(
client: _PscSpyChatClient,
provider: _PscSpyHistoryProvider,
*,
require_per_service_call_history_persistence: bool,
default_options: dict[str, Any] | None = None,
) -> Agent:
kwargs: dict[str, Any] = {}
if default_options is not None:
kwargs["default_options"] = default_options
return Agent(
client=client,
tools=[_psc_lookup_weather],
context_providers=[provider],
require_per_service_call_history_persistence=require_per_service_call_history_persistence,
**kwargs,
)
async def _psc_run(agent: Agent, text: str, session: AgentSession, *, stream: bool) -> str:
if stream:
chunks: list[str] = []
async for update in agent.run(text, session=session, stream=True):
chunks.append(update.text or "")
return "".join(chunks)
result = await agent.run(text, session=session)
return result.text
# driver=True (the contract under test): persistence happens per service call
@_psc_stream_params
async def test_psc_flag_on_store_false_persists_after_each_service_call(stream: bool) -> None:
"""Mode A (flag on, service does not store): function-call turn is persisted before the final call."""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=False, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
text = await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert text == "It is sunny in Seattle."
# Two service calls: function call, then final completion.
assert client.call_count == 2
# The contract: the function-call turn was persisted *before* the second service call started.
assert client.saves_before_call == [0, 1]
assert provider.save_calls == 2
# Mode A loads local history (the middleware injects it before each service call).
assert provider.get_calls >= 1
# No service-side storage, so no conversation id is propagated.
assert session.service_session_id is None
@_psc_stream_params
async def test_psc_flag_on_stores_by_default_persists_after_each_service_call(
stream: bool, caplog: pytest.LogCaptureFixture
) -> None:
"""Mode B (flag on, service stores by default): still persists per service call, but skips load (issue #5798)."""
provider = _PscSpyHistoryProvider() # load_messages=True by default
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
with caplog.at_level(logging.WARNING, logger="agent_framework"):
text = await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert text == "It is sunny in Seattle."
assert client.call_count == 2
# The invariant the bug violated: persistence still happens per service call when the service stores.
assert client.saves_before_call == [0, 1]
assert provider.save_calls == 2
# The service owns loading, so the provider is never asked to load.
assert provider.get_calls == 0
# A warning surfaces the bypassed load (load_messages=True).
assert any("load_messages" in record.message for record in caplog.records)
# The real service conversation id propagates to the session.
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
@_psc_stream_params
async def test_psc_flag_on_store_only_provider_no_load_no_warning(
stream: bool, caplog: pytest.LogCaptureFixture
) -> None:
"""Mode B with a store-only provider (load_messages=False): persists per call, no load, no warning."""
provider = _PscSpyHistoryProvider(load_messages=False)
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
with caplog.at_level(logging.WARNING, logger="agent_framework"):
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert client.saves_before_call == [0, 1]
assert provider.save_calls == 2
assert provider.get_calls == 0
assert not any("load_messages" in record.message for record in caplog.records)
@_psc_stream_params
async def test_psc_flag_on_store_false_override_behaves_as_mode_a(stream: bool) -> None:
"""Flag on + storing client but store=False override: falls back to Mode A (local, per call)."""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(
client, provider, require_per_service_call_history_persistence=True, default_options={"store": False}
)
session = agent.create_session()
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert client.saves_before_call == [0, 1]
assert provider.save_calls == 2
assert provider.get_calls >= 1
# store=False forces local handling, so no real service conversation id.
assert session.service_session_id is None
@_psc_stream_params
async def test_psc_flag_on_store_none_treated_as_absent(stream: bool, caplog: pytest.LogCaptureFixture) -> None:
"""Flag on + storing client + explicit store=None: None is "unset", so the storing default applies (Mode B)."""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(
client, provider, require_per_service_call_history_persistence=True, default_options={"store": None}
)
session = agent.create_session()
with caplog.at_level(logging.WARNING, logger="agent_framework"):
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert client.saves_before_call == [0, 1]
assert provider.save_calls == 2
assert provider.get_calls == 0
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
assert any("load_messages" in record.message for record in caplog.records)
# store=None must not be forwarded to the client; the service decides its own default.
assert all("store" not in options for options in client.received_options)
@_psc_stream_params
async def test_psc_flag_on_respects_store_outputs_flag(stream: bool) -> None:
"""Flag on: the provider's store_inputs/store_outputs flags still apply per service call."""
provider = _PscSpyHistoryProvider(store_inputs=True, store_outputs=False)
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert provider.save_calls == 2
# Outputs disabled, so no assistant/tool-call messages were stored, only user/tool inputs.
assert provider.stored_messages
assert all(message.role != "assistant" for message in provider.stored_messages)
# driver=False (control): persistence happens once, at the end of the run
@_psc_stream_params
async def test_psc_flag_off_store_false_persists_once_at_end(stream: bool) -> None:
"""Flag off + non-storing client: nothing is persisted mid-run; one save at the end."""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=False, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=False)
session = agent.create_session()
text = await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert text == "It is sunny in Seattle."
assert client.call_count == 2
# The control contract: no save happened between the function call and the final completion.
assert client.saves_before_call == [0, 0]
assert provider.save_calls == 1
@_psc_stream_params
async def test_psc_flag_off_stores_by_default_persists_once_at_end(stream: bool) -> None:
"""Flag off + storing client: once-per-run persistence, and the service conversation id propagates."""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=False)
session = agent.create_session()
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert client.saves_before_call == [0, 0]
assert provider.save_calls == 1
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
@_psc_stream_params
async def test_psc_flag_on_storing_with_existing_conversation_id_does_not_raise(stream: bool) -> None:
"""Allow side of the guard: flag on + storing client + an existing conversation_id resumes (no raise).
The non-storing path raises on an existing service-managed conversation id, but with a storing
client the run must proceed and the service conversation id must propagate to the session.
"""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
if stream:
chunks: list[str] = []
async for update in agent.run(
"What's the weather in Seattle?",
session=session,
stream=True,
options={"conversation_id": "existing_conversation"},
):
chunks.append(update.text or "")
text = "".join(chunks)
else:
result = await agent.run(
"What's the weather in Seattle?",
session=session,
options={"conversation_id": "existing_conversation"},
)
text = result.text
assert text == "It is sunny in Seattle."
# Persistence still happens per service call, and the real service id propagates to the session.
assert provider.save_calls == 2
assert provider.get_calls == 0
assert session.service_session_id == _PSC_SERVICE_CONVERSATION_ID
@_psc_stream_params
async def test_psc_flag_on_storing_two_runs_same_session(stream: bool) -> None:
"""Storing mode across two runs on one session: persistence keeps happening, id is stable, no load.
The second run exercises the precedence branch where the session already carries a
service_session_id, which must continue to skip provider loading and keep persisting.
"""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(provider=provider, stores_by_default=True, script=_psc_function_call_script())
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
assert provider.save_calls == 2
assert provider.get_calls == 0
first_run_service_id = session.service_session_id
assert first_run_service_id == _PSC_SERVICE_CONVERSATION_ID
# Reset the scripted client for a second run on the same session.
client._script = _psc_function_call_script()
client.call_count = 0
client.saves_before_call = []
await _psc_run(agent, "And in Portland?", session, stream=stream)
# Persistence keeps happening on the second run (two more saves), still per service call.
assert client.saves_before_call == [2, 3]
assert provider.save_calls == 4
# Loading stays skipped and the service conversation id stays stable across runs.
assert provider.get_calls == 0
assert session.service_session_id == first_run_service_id
@_psc_stream_params
async def test_psc_flag_on_storing_without_conversation_id_warns_every_call(
stream: bool, caplog: pytest.LogCaptureFixture
) -> None:
"""Storing mode but the client returns no conversation_id: warn on every service call.
Without an echoed conversation id the next run has nothing to resume from, so cross-turn
history can be lost silently. The warning fires per service call (no dedup) so the uncommon
failure mode cannot pass unnoticed.
"""
provider = _PscSpyHistoryProvider()
client = _PscSpyChatClient(
provider=provider,
stores_by_default=True,
script=_psc_function_call_script(),
echo_conversation_id=False,
)
agent = _psc_build_agent(client, provider, require_per_service_call_history_persistence=True)
session = agent.create_session()
with caplog.at_level(logging.WARNING, logger="agent_framework"):
await _psc_run(agent, "What's the weather in Seattle?", session, stream=stream)
# Persistence still happens, but no service id is captured to resume from.
assert provider.save_calls == 2
assert session.service_session_id is None
# Two service calls -> the warning is emitted twice (one per call, not deduped).
missing_id_warnings = [r for r in caplog.records if "returned no conversation_id" in r.message]
assert len(missing_id_warnings) == 2

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