mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c45c14250 | ||
|
|
6169df04cb | ||
|
|
331201294b | ||
|
|
fa9e086576 | ||
|
|
dcc218dbac | ||
|
|
6bd2cfec03 | ||
|
|
ab8ba8fc61 | ||
|
|
9cafd7e58b | ||
|
|
d5335fbeae | ||
|
|
bf4ad48cf2 | ||
|
|
01fc518b29 | ||
|
|
f3c3efed43 | ||
|
|
bbccb7c28c | ||
|
|
dbc312a78a | ||
|
|
bb9ed63a34 | ||
|
|
6b94315161 | ||
|
|
bc0e65d716 | ||
|
|
4268080c20 | ||
|
|
fe08574a7c | ||
|
|
f970a699d8 | ||
|
|
f29bae8fbc | ||
|
|
c3901a4ddd | ||
|
|
ba617fc3b5 | ||
|
|
afa7834e2e | ||
|
|
c6951c21f6 | ||
|
|
a982428916 | ||
|
|
90a3e5de47 | ||
|
|
49a6e433a3 |
@@ -8,6 +8,7 @@ function getPullRequest(context) {
|
||||
|
||||
return {
|
||||
author: pullRequest.user.login,
|
||||
authorType: pullRequest.user.type,
|
||||
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
|
||||
number: pullRequest.number,
|
||||
};
|
||||
@@ -49,6 +50,10 @@ function hasLabel(labels, labelName) {
|
||||
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
|
||||
}
|
||||
|
||||
function isDependabotAuthor({ author, authorType }) {
|
||||
return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]';
|
||||
}
|
||||
|
||||
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
|
||||
return [
|
||||
`Thank you for your contribution, @${author}.`,
|
||||
@@ -83,7 +88,17 @@ async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }
|
||||
|
||||
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const { author, labels, number } = getPullRequest(context);
|
||||
const { author, authorType, labels, number } = getPullRequest(context);
|
||||
|
||||
if (isDependabotAuthor({ author, authorType })) {
|
||||
core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`);
|
||||
return {
|
||||
author,
|
||||
closed: false,
|
||||
dependabotExempt: true,
|
||||
openPrCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLabel(labels, exemptLabelName)) {
|
||||
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
|
||||
|
||||
@@ -16,7 +16,7 @@ const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createContext({ author = 'community-user', labels = [], number = 123 } = {}) {
|
||||
function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
|
||||
return {
|
||||
repo: {
|
||||
owner: 'microsoft',
|
||||
@@ -28,6 +28,7 @@ function createContext({ author = 'community-user', labels = [], number = 123 }
|
||||
labels: labels.map((name) => ({ name })),
|
||||
user: {
|
||||
login: author,
|
||||
type: authorType,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -296,6 +297,30 @@ describe('PR limit enforcement', () => {
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
it('does not close Dependabot PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
pullRequests: createPullRequestPage({
|
||||
author: 'dependabot[bot]',
|
||||
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await enforcePrLimit({
|
||||
github,
|
||||
context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }),
|
||||
core: createCore(),
|
||||
exemptLabelName: 'pr-limit-exempt',
|
||||
maxOpenPrs: 10,
|
||||
labelName: 'too-many-prs',
|
||||
});
|
||||
|
||||
assert.equal(result.closed, false);
|
||||
assert.equal(result.dependabotExempt, true);
|
||||
assert.equal(result.openPrCount, null);
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
it('counts the current PR when the author has more than one page of open PRs', async () => {
|
||||
const github = createGithub({
|
||||
itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)],
|
||||
|
||||
@@ -474,6 +474,45 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Integration Tests - GitHub Copilot
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -490,6 +529,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -553,7 +593,8 @@ jobs:
|
||||
python-tests-functions,
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
foundryChanged: ${{ steps.filter.outputs.foundry }}
|
||||
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
@@ -85,6 +86,8 @@ jobs:
|
||||
- 'python/packages/foundry_hosting/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
github_copilot:
|
||||
- 'python/packages/github_copilot/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -658,6 +661,58 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# GitHub Copilot integration tests
|
||||
python-tests-github-copilot:
|
||||
name: Python Tests - GitHub Copilot Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.githubCopilotChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (GitHub Copilot integration)
|
||||
run: >
|
||||
uv run pytest --import-mode=importlib
|
||||
packages/github_copilot/tests
|
||||
-m integration
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
|
||||
with:
|
||||
path: ./python/pytest.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: GitHub Copilot integration test results
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: test-results-github-copilot
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
@@ -674,6 +729,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -735,6 +791,7 @@ jobs:
|
||||
python-tests-foundry,
|
||||
python-tests-foundry-hosting,
|
||||
python-tests-cosmos,
|
||||
python-tests-github-copilot,
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<!-- Hyperlight -->
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.8.0</VersionPrefix>
|
||||
<VersionPrefix>1.9.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260528</DateSuffix>
|
||||
<DateSuffix>260603</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.8.0</GitTag>
|
||||
<GitTag>1.9.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseHttpLogging();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ builder.Services.AddAGUI();
|
||||
// Configure to listen on port 8888
|
||||
builder.WebHost.UseUrls("http://localhost:8888");
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
[SendsMessage(typeof(List<ChatMessage>))]
|
||||
[SendsMessage(typeof(TurnToken))]
|
||||
private sealed partial class ConcurrentStartExecutor()
|
||||
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
[MessageHandler]
|
||||
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
|
||||
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
[MessageHandler]
|
||||
@@ -63,13 +67,16 @@ internal static partial class WorkflowHelper
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync() => default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(List<ChatMessage>))]
|
||||
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed partial class ConcurrentAggregationExecutor() :
|
||||
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
|
||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
this._messages.Clear();
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.UseHttpLogging();
|
||||
|
||||
@@ -49,8 +49,9 @@ var agent = new AzureOpenAIClient(
|
||||
AGUIServerSerializerContext.Default.Options)
|
||||
]);
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// Register the agent with the host and configure it to use an in-memory session store
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AspNetCore\Microsoft.Agents.AI.Hosting.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -12,6 +12,11 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
// deployments, e.g.:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
@@ -32,11 +34,19 @@ public static class ChatClientHarnessExtensions
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
/// </param>
|
||||
/// <param name="services">
|
||||
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="HarnessAgent"/> instance.</returns>
|
||||
public static HarnessAgent AsHarnessAgent(
|
||||
this IChatClient chatClient,
|
||||
int maxContextWindowTokens,
|
||||
int maxOutputTokens,
|
||||
HarnessAgentOptions? options = null) =>
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
||||
HarnessAgentOptions? options = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null) =>
|
||||
new(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -105,6 +106,12 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// additional context providers, and chat history provider.
|
||||
/// When <see langword="null"/>, the agent uses built-in default settings.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory for creating loggers used by the agent and its components.
|
||||
/// </param>
|
||||
/// <param name="services">
|
||||
/// Optional service provider for resolving dependencies required by AI functions and other agent components.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="chatClient"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
@@ -112,24 +119,26 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
/// <paramref name="maxContextWindowTokens"/> is not positive, or
|
||||
/// <paramref name="maxOutputTokens"/> is negative or greater than or equal to <paramref name="maxContextWindowTokens"/>.
|
||||
/// </exception>
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null)
|
||||
public HarnessAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null)
|
||||
: base(BuildAgent(
|
||||
Throw.IfNull(chatClient),
|
||||
maxContextWindowTokens,
|
||||
maxOutputTokens,
|
||||
options))
|
||||
options,
|
||||
loggerFactory,
|
||||
services))
|
||||
{
|
||||
}
|
||||
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
private static AIAgent BuildAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options);
|
||||
ChatClientAgent innerAgent = BuildInnerAgent(chatClient, maxContextWindowTokens, maxOutputTokens, options, loggerFactory, services);
|
||||
|
||||
AIAgentBuilder builder = innerAgent.AsBuilder();
|
||||
|
||||
if (options?.DisableToolApproval is not true)
|
||||
{
|
||||
builder.UseToolApproval();
|
||||
builder.UseToolApproval(options?.ToolApprovalAgentOptions);
|
||||
}
|
||||
|
||||
if (options?.DisableOpenTelemetry is not true)
|
||||
@@ -137,10 +146,10 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
return builder.Build(services);
|
||||
}
|
||||
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options)
|
||||
private static ChatClientAgent BuildInnerAgent(IChatClient chatClient, int maxContextWindowTokens, int maxOutputTokens, HarnessAgentOptions? options, ILoggerFactory? loggerFactory, IServiceProvider? services)
|
||||
{
|
||||
var compactionStrategy = new ContextWindowCompactionStrategy(
|
||||
maxContextWindowTokens: maxContextWindowTokens,
|
||||
@@ -165,13 +174,13 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
|
||||
ChatOptions chatOptions = BuildChatOptions(options, instructions, maxOutputTokens);
|
||||
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy);
|
||||
var compactionProvider = new CompactionProvider(compactionStrategy, loggerFactory: loggerFactory);
|
||||
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options);
|
||||
IEnumerable<AIContextProvider> contextProviders = BuildContextProviders(options, loggerFactory);
|
||||
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
.UseFunctionInvocation(loggerFactory, configure: options?.MaximumIterationsPerRequest is int maxIterations
|
||||
? ficc => ficc.MaximumIterationsPerRequest = maxIterations
|
||||
: null)
|
||||
.UseMessageInjection()
|
||||
@@ -189,7 +198,9 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
},
|
||||
loggerFactory,
|
||||
services);
|
||||
}
|
||||
|
||||
private static ChatOptions BuildChatOptions(HarnessAgentOptions? options, string instructions, int maxOutputTokens)
|
||||
@@ -215,7 +226,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options)
|
||||
private static List<AIContextProvider> BuildContextProviders(HarnessAgentOptions? options, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
var providers = new List<AIContextProvider>();
|
||||
|
||||
@@ -255,8 +266,8 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
if (options?.DisableAgentSkillsProvider is not true)
|
||||
{
|
||||
AgentSkillsProvider skillsProvider = options?.AgentSkillsSource is AgentSkillsSource source
|
||||
? new AgentSkillsProvider(source)
|
||||
: new AgentSkillsProvider(Directory.GetCurrentDirectory());
|
||||
? new AgentSkillsProvider(source, loggerFactory: loggerFactory)
|
||||
: new AgentSkillsProvider(Directory.GetCurrentDirectory(), loggerFactory: loggerFactory);
|
||||
|
||||
providers.Add(skillsProvider);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,15 @@ 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 the <see cref="FileMemoryProvider"/> is disabled.
|
||||
/// </summary>
|
||||
|
||||
+10
-1
@@ -103,7 +103,16 @@ public static class AGUIEndpointRouteBuilderExtensions
|
||||
ArgumentNullException.ThrowIfNull(aiAgent);
|
||||
|
||||
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
|
||||
|
||||
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
|
||||
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new NoopAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore);
|
||||
|
||||
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
|
||||
+10
-1
@@ -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(value => value.Properties.Values.First().ToFormula())];
|
||||
this._values = [.. tableValue.Values.Select(ToLoopValue)];
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -99,6 +99,15 @@ 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
|
||||
|
||||
+63
-6
@@ -27,6 +27,14 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
||||
{
|
||||
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of evaluated parameters at approval-request time.
|
||||
/// Used to prevent TOCTOU attacks where state mutates during the approval window.
|
||||
/// </summary>
|
||||
private ApprovalSnapshot? _approvalSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Step identifiers for the MCP tool invocation workflow.
|
||||
/// </summary>
|
||||
@@ -75,6 +83,10 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
if (requireApproval)
|
||||
{
|
||||
// Snapshot the evaluated parameters to prevent TOCTOU attacks.
|
||||
// If state mutates during the approval window, the approved values are used on resume.
|
||||
this._approvalSnapshot = new ApprovalSnapshot(serverUrl, serverLabel, toolName, arguments, connectionName);
|
||||
|
||||
// Create tool call content for approval request.
|
||||
// Transport headers (e.g. Authorization) are intentionally excluded from the
|
||||
// approval event: they must not cross into the externally-surfaced approval request.
|
||||
@@ -137,13 +149,14 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved - now invoke the tool
|
||||
string serverUrl = this.GetServerUrl();
|
||||
string? serverLabel = this.GetServerLabel();
|
||||
string toolName = this.GetToolName();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
// Approved - use the snapshot from approval-request time to prevent TOCTOU attacks.
|
||||
// Headers are re-evaluated (they may contain auth secrets that should not be persisted).
|
||||
string serverUrl = this._approvalSnapshot?.ServerUrl ?? this.GetServerUrl();
|
||||
string? serverLabel = this._approvalSnapshot?.ServerLabel ?? this.GetServerLabel();
|
||||
string toolName = this._approvalSnapshot?.ToolName ?? this.GetToolName();
|
||||
Dictionary<string, object?>? arguments = this._approvalSnapshot?.Arguments ?? this.GetArguments();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
string? connectionName = this._approvalSnapshot?.ConnectionName ?? this.GetConnectionName();
|
||||
|
||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||
serverUrl,
|
||||
@@ -162,9 +175,33 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
/// </summary>
|
||||
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
||||
{
|
||||
// Clear the approval snapshot after successful completion.
|
||||
this._approvalSnapshot = null;
|
||||
await ClearSnapshotStateAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the approval snapshot from workflow state after a checkpoint restore.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
||||
{
|
||||
bool autoSend = this.GetAutoSendValue();
|
||||
@@ -365,4 +402,24 @@ internal sealed class InvokeMcpToolExecutor(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the persisted approval snapshot state after a successful tool invocation.
|
||||
/// </summary>
|
||||
private static async ValueTask ClearSnapshotStateAsync(IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the evaluated parameters at approval-request time so that
|
||||
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
|
||||
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalSnapshot(
|
||||
string ServerUrl,
|
||||
string? ServerLabel,
|
||||
string ToolName,
|
||||
Dictionary<string, object?>? Arguments,
|
||||
string? ConnectionName);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,36 @@ 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>
|
||||
@@ -199,5 +229,6 @@ public sealed class ChatClientAgentOptions
|
||||
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
||||
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
|
||||
EnableMessageInjection = this.EnableMessageInjection,
|
||||
EnableNonApprovalRequiredFunctionBypassing = this.EnableNonApprovalRequiredFunctionBypassing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,4 +148,35 @@ 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,6 +53,17 @@ 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) =>
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
// 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,20 +51,22 @@ 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="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 name="options">
|
||||
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
|
||||
/// When <see langword="null"/>, default settings are used.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
|
||||
public ToolApprovalAgent(AIAgent innerAgent, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
public ToolApprovalAgent(AIAgent innerAgent, ToolApprovalAgentOptions? options = null)
|
||||
: base(innerAgent)
|
||||
{
|
||||
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
this._jsonSerializerOptions = options?.JsonSerializerOptions ?? AgentJsonUtilities.DefaultOptions;
|
||||
this._autoApprovalRules = options?.AutoApprovalRules?.ToArray();
|
||||
this._sessionState = new ProviderSessionState<ToolApprovalState>(
|
||||
_ => new ToolApprovalState(),
|
||||
"toolApprovalState",
|
||||
@@ -79,7 +81,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Steps 1–2: Unwrap AlwaysApprove wrappers, process any queued approval requests.
|
||||
var (state, callerMessages, nextQueuedItem) = this.PrepareInboundMessages(messages, session);
|
||||
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
|
||||
|
||||
if (nextQueuedItem is not null)
|
||||
{
|
||||
@@ -98,7 +100,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 = this.ProcessAndQueueOutboundApprovalRequests(response.Messages, state, session);
|
||||
bool allAutoApproved = await this.ProcessAndQueueOutboundApprovalRequestsAsync(response.Messages, state, session).ConfigureAwait(false);
|
||||
|
||||
if (!allAutoApproved)
|
||||
{
|
||||
@@ -119,7 +121,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) = this.PrepareInboundMessages(messages, session);
|
||||
var (state, callerMessages, nextQueuedItem) = await this.PrepareInboundMessagesAsync(messages, session).ConfigureAwait(false);
|
||||
|
||||
if (nextQueuedItem is not null)
|
||||
{
|
||||
@@ -197,7 +199,7 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 4. Classify the collected approval requests against standing rules.
|
||||
// 4. Classify the collected approval requests against standing rules and auto-approval rules.
|
||||
List<ToolApprovalRequestContent> unapproved = [];
|
||||
foreach (var tarc in streamedApprovalRequests)
|
||||
{
|
||||
@@ -206,6 +208,11 @@ 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);
|
||||
@@ -291,9 +298,9 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates queued approval requests against current rules and auto-approves any that now match.
|
||||
/// Re-evaluates queued approval requests against current rules and auto-approval rules, and auto-approves any that now match.
|
||||
/// </summary>
|
||||
private void DrainAutoApprovableFromQueue(ToolApprovalState state)
|
||||
private async ValueTask DrainAutoApprovableFromQueueAsync(ToolApprovalState state)
|
||||
{
|
||||
for (int i = state.QueuedApprovalRequests.Count - 1; i >= 0; i--)
|
||||
{
|
||||
@@ -303,6 +310,12 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,8 +331,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 (ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)
|
||||
PrepareInboundMessages(IEnumerable<ChatMessage> messages, AgentSession? session)
|
||||
private async ValueTask<(ToolApprovalState State, List<ChatMessage> CallerMessages, ToolApprovalRequestContent? NextQueuedItem)>
|
||||
PrepareInboundMessagesAsync(IEnumerable<ChatMessage> messages, AgentSession? session)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
@@ -337,7 +350,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.
|
||||
this.DrainAutoApprovableFromQueue(state);
|
||||
await this.DrainAutoApprovableFromQueueAsync(state).ConfigureAwait(false);
|
||||
|
||||
if (state.QueuedApprovalRequests.Count > 0)
|
||||
{
|
||||
@@ -386,15 +399,18 @@ 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 bool ProcessAndQueueOutboundApprovalRequests(
|
||||
private async ValueTask<bool> ProcessAndQueueOutboundApprovalRequestsAsync(
|
||||
IList<ChatMessage> responseMessages,
|
||||
ToolApprovalState state,
|
||||
AgentSession? session)
|
||||
{
|
||||
// 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>();
|
||||
// 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>();
|
||||
var unapproved = new List<ToolApprovalRequestContent>();
|
||||
int autoApprovedCount = 0;
|
||||
|
||||
foreach (var message in responseMessages)
|
||||
{
|
||||
@@ -404,7 +420,17 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
{
|
||||
if (MatchesRule(tarc, state.Rules, this._jsonSerializerOptions))
|
||||
{
|
||||
autoApproved.Add(tarc);
|
||||
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++;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -415,18 +441,12 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
// Nothing to process: no auto-approved items and at most one unapproved (no queueing needed).
|
||||
if (autoApproved.Count == 0 && unapproved.Count <= 1)
|
||||
// No responses were collected above in this case, so state is unmodified and safe to leave.
|
||||
if (autoApprovedCount == 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)
|
||||
@@ -439,14 +459,10 @@ 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.
|
||||
var toRemove = new HashSet<ToolApprovalRequestContent>(autoApproved);
|
||||
if (unapproved.Count > 1)
|
||||
for (int i = 1; i < unapproved.Count; i++)
|
||||
{
|
||||
for (int i = 1; i < unapproved.Count; i++)
|
||||
{
|
||||
toRemove.Add(unapproved[i]);
|
||||
state.QueuedApprovalRequests.Add(unapproved[i]);
|
||||
}
|
||||
toRemove.Add(unapproved[i]);
|
||||
state.QueuedApprovalRequests.Add(unapproved[i]);
|
||||
}
|
||||
|
||||
// Walk messages in reverse and strip marked items.
|
||||
@@ -663,8 +679,36 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares stored rule arguments against actual function call arguments for an exact match.
|
||||
/// Checks whether a <see cref="ToolApprovalRequestContent"/> is approved by any of the configured
|
||||
/// auto-approval rules (heuristic functions).
|
||||
/// </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)
|
||||
|
||||
+5
-6
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -17,9 +16,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="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 name="options">
|
||||
/// Optional <see cref="ToolApprovalAgentOptions"/> for configuring serialization and auto-approval rules.
|
||||
/// When <see langword="null"/>, default settings are 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>
|
||||
@@ -32,6 +31,6 @@ public static class ToolApprovalAgentBuilderExtensions
|
||||
/// </remarks>
|
||||
public static AIAgentBuilder UseToolApproval(
|
||||
this AIAgentBuilder builder,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, jsonSerializerOptions));
|
||||
ToolApprovalAgentOptions? options = null)
|
||||
=> Throw.IfNull(builder).Use(innerAgent => new ToolApprovalAgent(innerAgent, options));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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,14 +49,13 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content. When the skill has scripts, a
|
||||
/// <c><scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts></c>
|
||||
/// block is appended with a per-script entry describing the expected argument format.
|
||||
/// <c><script_schemas></c> block is appended describing the argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ public abstract class AgentClassSkill<
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
@@ -147,11 +146,17 @@ public abstract class AgentClassSkill<
|
||||
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation returns resources discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
|
||||
@@ -159,11 +164,17 @@ public abstract class AgentClassSkill<
|
||||
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default implementation returns scripts discovered via reflection by scanning
|
||||
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
|
||||
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
|
||||
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only script parameter schemas are included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
|
||||
@@ -184,6 +195,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill resource backed by a static value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -194,6 +209,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill resource backed by a delegate that produces a dynamic value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -208,6 +227,10 @@ public abstract class AgentClassSkill<
|
||||
/// <summary>
|
||||
/// Creates a skill script backed by a delegate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -115,6 +115,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <summary>
|
||||
/// Registers a static resource with this skill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -129,6 +133,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a dynamic resource with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
@@ -147,6 +155,10 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a script with this skill, backed by a C# delegate.
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
/// <param name="description">An optional description of the script.</param>
|
||||
|
||||
+13
-41
@@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI;
|
||||
internal static class AgentInlineSkillContentBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete skill content containing name, description, instructions, resources, and scripts.
|
||||
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
|
||||
/// </summary>
|
||||
/// <param name="name">The skill name.</param>
|
||||
/// <param name="description">The skill description.</param>
|
||||
/// <param name="instructions">The raw instructions text.</param>
|
||||
/// <param name="resources">Optional resources associated with the skill.</param>
|
||||
/// <param name="scripts">Optional scripts associated with the skill.</param>
|
||||
/// <returns>An XML-structured content string.</returns>
|
||||
public static string Build(
|
||||
string name,
|
||||
string description,
|
||||
string instructions,
|
||||
IReadOnlyList<AgentSkillResource>? resources,
|
||||
IReadOnlyList<AgentSkillScript>? scripts)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(name);
|
||||
@@ -39,41 +37,24 @@ internal static class AgentInlineSkillContentBuilder
|
||||
.Append(EscapeXmlString(instructions))
|
||||
.Append("\n</instructions>");
|
||||
|
||||
if (resources is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n<resources>\n");
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
if (resource.Description is not null)
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</resources>");
|
||||
}
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptsBlock(scripts));
|
||||
sb.Append(BuildScriptSchemasBlock(scripts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c><scripts>...</scripts></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><script name="..."></c> element with optional
|
||||
/// <c>description</c> attribute and <c><parameters_schema></c> child element.
|
||||
/// Builds a <c><script_schemas>...</script_schemas></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><schema script="..."></c> element containing only
|
||||
/// the parameter schema. This block serves as a reference for the model to know how to
|
||||
/// format arguments when calling scripts, not as a discovery mechanism.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <returns>An XML string starting with <c>\n<scripts></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
/// <returns>An XML string starting with <c>\n<script_schemas></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
@@ -83,32 +64,23 @@ internal static class AgentInlineSkillContentBuilder
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<scripts>\n");
|
||||
sb.Append("\n<script_schemas>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
var parametersSchema = script.ParametersSchema;
|
||||
|
||||
if (script.Description is null && parametersSchema is null)
|
||||
if (parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(script.Description is not null
|
||||
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
|
||||
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
|
||||
if (parametersSchema is not null)
|
||||
{
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
}
|
||||
|
||||
sb.Append(" </script>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</scripts>");
|
||||
sb.Append("</script_schemas>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
#endif
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
@@ -643,6 +644,51 @@ 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: OpenTelemetry
|
||||
@@ -1460,4 +1506,131 @@ public class HarnessAgentTests
|
||||
|
||||
#endregion
|
||||
#endif
|
||||
|
||||
#region LoggerFactory and ServiceProvider
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when loggerFactory is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithLoggerFactory()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when serviceProvider is provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the constructor succeeds when both loggerFactory and serviceProvider are provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_SucceedsWithLoggerFactoryAndServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that AsHarnessAgent extension method accepts loggerFactory and serviceProvider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AsHarnessAgent_SucceedsWithLoggerFactoryAndServiceProvider()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var loggerFactory = new Mock<ILoggerFactory>().Object;
|
||||
var services = new Mock<IServiceProvider>().Object;
|
||||
|
||||
// Act
|
||||
var agent = chatClient.AsHarnessAgent(TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), loggerFactory, services);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that ILoggerFactory is threaded to downstream components by confirming CreateLogger is called.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_LoggerFactoryIsUsedByDownstreamComponents()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var mockLoggerFactory = new Mock<ILoggerFactory>();
|
||||
mockLoggerFactory
|
||||
.Setup(lf => lf.CreateLogger(It.IsAny<string>()))
|
||||
.Returns(new Mock<ILogger>().Object);
|
||||
|
||||
// Act — use options that leave CompactionProvider and AgentSkillsProvider enabled
|
||||
var options = new HarnessAgentOptions
|
||||
{
|
||||
DisableToolApproval = true,
|
||||
DisableOpenTelemetry = true,
|
||||
DisableFileMemory = true,
|
||||
DisableFileAccess = true,
|
||||
DisableWebSearch = true,
|
||||
DisableTodoProvider = true,
|
||||
DisableAgentModeProvider = true,
|
||||
};
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options, mockLoggerFactory.Object);
|
||||
|
||||
// Assert — CreateLogger should have been called by one or more downstream components
|
||||
Assert.NotNull(agent);
|
||||
mockLoggerFactory.Verify(lf => lf.CreateLogger(It.IsAny<string>()), Times.AtLeastOnce());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that IServiceProvider is propagated through the agent pipeline by confirming
|
||||
/// it is queried during agent construction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ServiceProviderIsQueriedDuringBuild()
|
||||
{
|
||||
// Arrange
|
||||
var chatClient = new Mock<IChatClient>().Object;
|
||||
var mockServices = new Mock<IServiceProvider>();
|
||||
mockServices
|
||||
.Setup(sp => sp.GetService(It.IsAny<Type>()))
|
||||
.Returns(null!);
|
||||
|
||||
// Act
|
||||
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, CreateAllDisabledOptions(), services: mockServices.Object);
|
||||
|
||||
// Assert — the service provider should have been queried during pipeline construction
|
||||
Assert.NotNull(agent);
|
||||
mockServices.Verify(sp => sp.GetService(It.IsAny<Type>()), Times.AtLeastOnce());
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -51,9 +51,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act & Assert — Content is cached
|
||||
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — Content includes parameter schema from typed script
|
||||
Assert.Contains("parameters_schema", await skill.GetContentAsync());
|
||||
Assert.Contains("value", await skill.GetContentAsync());
|
||||
// Act & Assert — Content includes parameter schema from typed script (with preserved quotes)
|
||||
Assert.Contains("\"value\"", await skill.GetContentAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -383,10 +382,9 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes reflected resources and scripts
|
||||
Assert.Contains("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<scripts>", await skill.GetContentAsync());
|
||||
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
|
||||
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
@@ -504,7 +502,7 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
|
||||
public async Task Content_DoesNotRenderResources_InBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
@@ -512,8 +510,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — descriptions from [Description] attribute appear in synthesized content
|
||||
Assert.Contains("Some important data.", content);
|
||||
// Assert — resources are no longer rendered in body content
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -122,11 +122,10 @@ public sealed class AgentFileSkillScriptTests
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script name=\"build\">", content);
|
||||
Assert.Contains("<script name=\"deploy\">", content);
|
||||
Assert.Contains("<parameters_schema>", content);
|
||||
Assert.Contains("</scripts>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("<schema script=\"build\">", content);
|
||||
Assert.Contains("<schema script=\"deploy\">", content);
|
||||
Assert.Contains("</script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -158,13 +158,12 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("config", content);
|
||||
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -173,9 +172,8 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("dynamic", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -189,7 +187,7 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("run", content);
|
||||
}
|
||||
|
||||
@@ -209,7 +207,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
|
||||
public async Task Content_IncludesScriptSchemasAddedBeforeFirstAccessAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -220,9 +218,8 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<resources>", content);
|
||||
Assert.Contains("r1", content);
|
||||
Assert.Contains("<scripts>", content);
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("s1", content);
|
||||
}
|
||||
|
||||
@@ -236,8 +233,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present and XML content chars escaped
|
||||
Assert.Contains("parameters_schema", content);
|
||||
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
|
||||
Assert.Contains("<schema script=\"search\">", content);
|
||||
Assert.Contains("\"query\"", content);
|
||||
Assert.DoesNotContain("<![CDATA[", content);
|
||||
}
|
||||
|
||||
@@ -429,7 +427,7 @@ public sealed class AgentInlineSkillTests
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("<scripts>", content);
|
||||
Assert.DoesNotContain("<script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -463,7 +461,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public async Task Content_ScriptWithDescription_DoesNotEmitDescriptionAttributeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -472,8 +470,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"Runs something.\"", content);
|
||||
// Assert — description is no longer emitted in the script_schemas block;
|
||||
// the block only contains parameter schemas for calling scripts.
|
||||
Assert.Contains("<schema script=\"my-script\"", content);
|
||||
Assert.DoesNotContain("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -492,7 +492,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
|
||||
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -502,9 +502,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("description=\"A described resource.\"", content);
|
||||
Assert.DoesNotContain("no-desc\" description", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("with-desc", content);
|
||||
Assert.DoesNotContain("no-desc", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -134,6 +134,7 @@ public class ChatClientAgentOptionsTests
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
EnableNonApprovalRequiredFunctionBypassing = true,
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -150,6 +151,7 @@ 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);
|
||||
|
||||
+574
@@ -0,0 +1,574 @@
|
||||
// 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
|
||||
}
|
||||
+3
-3
@@ -59,15 +59,15 @@ public class ToolApprovalAgentBuilderExtensionsTests
|
||||
/// Verify that UseToolApproval with custom JsonSerializerOptions works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UseToolApproval_WithCustomJsonSerializerOptions_ReturnsToolApprovalAgent()
|
||||
public void UseToolApproval_WithCustomOptions_ReturnsToolApprovalAgent()
|
||||
{
|
||||
// Arrange
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
var builder = new AIAgentBuilder(mockAgent.Object);
|
||||
var options = new JsonSerializerOptions();
|
||||
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
|
||||
|
||||
// Act
|
||||
var result = builder.UseToolApproval(jsonSerializerOptions: options).Build();
|
||||
var result = builder.UseToolApproval(options: options).Build();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ToolApprovalAgent>(result);
|
||||
|
||||
+310
-3
@@ -47,14 +47,14 @@ public class ToolApprovalAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that constructor accepts custom JsonSerializerOptions.
|
||||
/// Verify that constructor accepts custom options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_CustomJsonSerializerOptions_CreatesInstanceAsync()
|
||||
public void Constructor_CustomOptions_CreatesInstance()
|
||||
{
|
||||
// Arrange
|
||||
var innerAgent = new Mock<AIAgent>().Object;
|
||||
var options = new JsonSerializerOptions();
|
||||
var options = new ToolApprovalAgentOptions { JsonSerializerOptions = new JsonSerializerOptions() };
|
||||
|
||||
// Act
|
||||
var agent = new ToolApprovalAgent(innerAgent, options);
|
||||
@@ -1535,4 +1535,311 @@ 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
|
||||
}
|
||||
|
||||
+89
@@ -142,6 +142,95 @@ 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()
|
||||
{
|
||||
|
||||
+354
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
@@ -11,7 +12,9 @@ using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeMcpToolExecutor.ApprovalSnapshot;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
@@ -842,6 +845,313 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approval Snapshot Security Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating the tool name variable after approval does not change
|
||||
/// which tool is actually invoked. The originally-approved tool name must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesApprovedToolNameNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedToolName = "safe_readonly_query";
|
||||
const string MutatedToolName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetTool", FormulaValue.New(ApprovedToolName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableToolName(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesApprovedToolNameNotMutatedAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
variableName: "TargetTool");
|
||||
|
||||
string? capturedToolName = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("TargetTool", FormulaValue.New(MutatedToolName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve (they saw "safe_readonly_query" in the approval UI)
|
||||
McpServerToolCallContent toolCall = new(action.Id, ApprovedToolName, TestServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved tool name must be used, not the mutated one
|
||||
Assert.NotNull(capturedToolName);
|
||||
Assert.Equal(ApprovedToolName, capturedToolName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating an argument variable after approval does not change
|
||||
/// the arguments actually passed to the MCP tool. The originally-approved arguments must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedQuery = "SELECT * FROM users LIMIT 10";
|
||||
const string MutatedQuery = "DROP TABLE users CASCADE; --";
|
||||
|
||||
this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableArgument(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: TestToolName,
|
||||
argumentKey: "query",
|
||||
variableName: "SqlQuery");
|
||||
|
||||
IDictionary<string, object?>? capturedArguments = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, _, arguments, _, _, _) => capturedArguments = arguments)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved argument must be used, not the mutated one
|
||||
Assert.NotNull(capturedArguments);
|
||||
Assert.Equal(ApprovedQuery, capturedArguments["query"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating the server URL variable after approval does not redirect
|
||||
/// the MCP tool call to a different server. The originally-approved server URL must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesApprovedServerUrlNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedServerUrl = "https://internal-mcp.corp";
|
||||
const string MutatedServerUrl = "https://attacker.evil/steal";
|
||||
|
||||
this.State.Set("McpEndpoint", FormulaValue.New(ApprovedServerUrl));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableServerUrl(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesApprovedServerUrlNotMutatedAsync),
|
||||
variableName: "McpEndpoint",
|
||||
toolName: TestToolName);
|
||||
|
||||
string? capturedServerUrl = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(serverUrl, _, _, _, _, _, _) => capturedServerUrl = serverUrl)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("McpEndpoint", FormulaValue.New(MutatedServerUrl));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
McpServerToolCallContent toolCall = new(action.Id, TestToolName, ApprovedServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved server URL must be used, not the mutated one
|
||||
Assert.NotNull(capturedServerUrl);
|
||||
Assert.Equal(ApprovedServerUrl, capturedServerUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the approval snapshot survives a checkpoint/restore cycle.
|
||||
/// After restore, the originally-approved tool name must still be used even if state was mutated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedToolName = "safe_readonly_query";
|
||||
const string MutatedToolName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetTool", FormulaValue.New(ApprovedToolName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeMcpTool model = this.CreateModelWithVariableToolName(
|
||||
displayName: nameof(InvokeMcpToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
variableName: "TargetTool");
|
||||
|
||||
string? capturedToolName = null;
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("capture-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("result")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate checkpoint: persist to state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate restore on a "new" executor instance by clearing the in-memory field via reflection
|
||||
// (In production, a new executor instance would be created with _approvalSnapshot == null)
|
||||
typeof(InvokeMcpToolExecutor)
|
||||
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(action, null);
|
||||
|
||||
// Restore from state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Mutate state after restore (simulating parallel branch)
|
||||
this.State.Set("TargetTool", FormulaValue.New(MutatedToolName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
McpServerToolCallContent toolCall = new(action.Id, ApprovedToolName, TestServerUrl);
|
||||
ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true);
|
||||
ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved tool name must be used, not the mutated one
|
||||
Assert.NotNull(capturedToolName);
|
||||
Assert.Equal(ApprovedToolName, capturedToolName);
|
||||
}
|
||||
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContext()
|
||||
{
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests).
|
||||
/// </summary>
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContextWithStateStore()
|
||||
{
|
||||
Dictionary<string, object?> stateStore = new();
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<ApprovalSnapshot?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, ApprovalSnapshot?, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value)
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.ReadStateAsync<ApprovalSnapshot>(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string?, CancellationToken>((key, _, _) =>
|
||||
new ValueTask<ApprovalSnapshot?>(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null));
|
||||
mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<string>());
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a protected method on an executor via reflection (for testing checkpoint hooks).
|
||||
/// </summary>
|
||||
private static async ValueTask InvokeProtectedMethodAsync(InvokeMcpToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
MethodInfo method = typeof(InvokeMcpToolExecutor)
|
||||
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!;
|
||||
await result.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CompleteAsync Tests
|
||||
|
||||
[Fact]
|
||||
@@ -951,6 +1261,50 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModelWithVariableToolName(string displayName, string serverUrl, string variableName)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)),
|
||||
ToolName = new StringExpression.Builder(
|
||||
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModelWithVariableArgument(
|
||||
string displayName, string serverUrl, string toolName, string argumentKey, string variableName)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(StringExpression.Literal(serverUrl)),
|
||||
ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
builder.Arguments.Add(argumentKey,
|
||||
ValueExpression.Variable(PropertyPath.TopicVariable(variableName)));
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeMcpTool CreateModelWithVariableServerUrl(string displayName, string variableName, string toolName)
|
||||
{
|
||||
InvokeMcpTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
ServerUrl = new StringExpression.Builder(
|
||||
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
|
||||
ToolName = new StringExpression.Builder(StringExpression.Literal(toolName)),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
return AssignParent<InvokeMcpTool>(builder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mock MCP Tool Provider
|
||||
|
||||
+39
-1
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.8.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add MCP-based skills discovery (`McpSkillsSource`) ([#6169](https://github.com/microsoft/agent-framework/pull/6169))
|
||||
- **agent-framework-core**: Progressive tool exposure via `FunctionInvocationContext` ([#6233](https://github.com/microsoft/agent-framework/pull/6233))
|
||||
- **agent-framework-core**: Add background agent support to harness agent ([#6155](https://github.com/microsoft/agent-framework/pull/6155))
|
||||
- **agent-framework-core**: Add `AgentFileStore` and `FileAccessProvider` for file access operations ([#6099](https://github.com/microsoft/agent-framework/pull/6099))
|
||||
- **agent-framework-core**: Coalesce code interpreter history chunks ([#5801](https://github.com/microsoft/agent-framework/pull/5801))
|
||||
- **agent-framework-core**: Run sync tools off the event loop ([#5773](https://github.com/microsoft/agent-framework/pull/5773))
|
||||
- **agent-framework-bedrock**: Implement native structured output support via Converse API ([#6052](https://github.com/microsoft/agent-framework/pull/6052))
|
||||
- **agent-framework-foundry**: Add Foundry Adaptive Evals integration for rubric-generation ([#6101](https://github.com/microsoft/agent-framework/pull/6101))
|
||||
- **agent-framework-foundry**: Add `timeout` parameter to `FoundryAgent` to fix `ConnectTimeout` on multi-turn conversations ([#6263](https://github.com/microsoft/agent-framework/pull/6263))
|
||||
- **agent-framework-mistral**: Add Mistral AI embedding client package ([#5480](https://github.com/microsoft/agent-framework/pull/5480))
|
||||
- **agent-framework-a2a**: Expose `supported_protocol_bindings` as configurable parameter ([#6098](https://github.com/microsoft/agent-framework/pull/6098))
|
||||
- **agent-framework-a2a**: Set `message_id` on `AgentResponseUpdate` for message-bearing paths ([#6163](https://github.com/microsoft/agent-framework/pull/6163))
|
||||
- **agent-framework-foundry-hosting**: Persist hosted MCP call/results as canonical `mcp_call` output ([#6070](https://github.com/microsoft/agent-framework/pull/6070))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-github-copilot**: [BREAKING] Upgrade `github-copilot-sdk` to v1.0.0 (stable) ([#6292](https://github.com/microsoft/agent-framework/pull/6292))
|
||||
- **agent-framework-core**: [BREAKING — experimental] Refactor Skill API to async resource and script lookup ([#6135](https://github.com/microsoft/agent-framework/pull/6135))
|
||||
- **agent-framework-github-copilot**: Promote to release candidate (`1.0.0rc1`)
|
||||
- **agent-framework-declarative**: Promote to release candidate (`1.0.0rc1`) ([#6256](https://github.com/microsoft/agent-framework/pull/6256))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Fix compaction message-id collisions and tool-loop summary persistence ([#6299](https://github.com/microsoft/agent-framework/pull/6299))
|
||||
- **agent-framework-core**: Fix observability unsafe serialization of function-call arguments containing dataclass/framework objects ([#6026](https://github.com/microsoft/agent-framework/pull/6026))
|
||||
- **agent-framework-core**: Consolidate MCP reliability fixes ([#6145](https://github.com/microsoft/agent-framework/pull/6145))
|
||||
- **agent-framework-core**: Backfill chat span request model if unknown and response model is available ([#6160](https://github.com/microsoft/agent-framework/pull/6160))
|
||||
- **agent-framework-anthropic**: Skip orphan anthropic thinking signatures ([#5784](https://github.com/microsoft/agent-framework/pull/5784))
|
||||
- **agent-framework-foundry**: Fix `FoundryAgent` stripping model from `PromptAgent` requests ([#5526](https://github.com/microsoft/agent-framework/pull/5526))
|
||||
- **agent-framework-foundry-hosting**: Fix toolbox consent flow in hosted agent ([#6249](https://github.com/microsoft/agent-framework/pull/6249))
|
||||
- **agent-framework-foundry-hosting**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
|
||||
- **agent-framework-openai**: Fix OTLP HTTP base-endpoint losing `/v1/{signal}` auto-append ([#5913](https://github.com/microsoft/agent-framework/pull/5913))
|
||||
- **agent-framework-openai**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
|
||||
- **agent-framework-orchestrations**: Fix spurious Magentic custom manager warning ([#6261](https://github.com/microsoft/agent-framework/pull/6261))
|
||||
- **agent-framework-azurefunctions**: Fix integration test worker crashes on Py3.13 ([#4260](https://github.com/microsoft/agent-framework/pull/4260))
|
||||
|
||||
## [1.7.0] - 2026-05-28
|
||||
|
||||
### Added
|
||||
@@ -1132,7 +1169,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...HEAD
|
||||
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
|
||||
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
|
||||
@@ -33,7 +33,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260528"
|
||||
version = "1.0.0b260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,8 +22,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260521,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"agent-framework-durabletask>=1.0.0b260604,<2",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
]
|
||||
|
||||
@@ -795,10 +795,7 @@ class BedrockChatClient(
|
||||
schema = copy.deepcopy(schema_src)
|
||||
else:
|
||||
if not isinstance(response_format, type) or not issubclass(response_format, BaseModel):
|
||||
raise TypeError(
|
||||
"response_format must be None, a dict JSON schema, "
|
||||
"or a Pydantic BaseModel subclass."
|
||||
)
|
||||
raise TypeError("response_format must be None, a dict JSON schema, or a Pydantic BaseModel subclass.")
|
||||
# response_format is a Pydantic model class
|
||||
schema = response_format.model_json_schema()
|
||||
name = response_format.__name__
|
||||
@@ -817,9 +814,7 @@ class BedrockChatClient(
|
||||
return {
|
||||
"textFormat": {
|
||||
"type": "json_schema",
|
||||
"structure": {
|
||||
"jsonSchema": json_schema
|
||||
},
|
||||
"structure": {"jsonSchema": json_schema},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,9 +835,7 @@ class BedrockChatClient(
|
||||
if node_id in visited:
|
||||
return
|
||||
visited.add(node_id)
|
||||
if node.get("type") == "object" or (
|
||||
"properties" in node and "type" not in node
|
||||
):
|
||||
if node.get("type") == "object" or ("properties" in node and "type" not in node):
|
||||
existing = node.get("additionalProperties")
|
||||
if existing is None or existing is True:
|
||||
node["additionalProperties"] = False
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -238,6 +238,7 @@ async def test_chat_response_value_populated_streaming() -> None:
|
||||
|
||||
async def test_unsupported_model_validation_exception() -> None:
|
||||
"""When a model doesn't support outputConfig, a clear error should be raised."""
|
||||
|
||||
class _FailingStubBedrockRuntime:
|
||||
def converse(self, **kwargs: Any) -> dict[str, Any]:
|
||||
# Simulate botocore ClientError for ValidationException
|
||||
|
||||
@@ -56,7 +56,7 @@ agent_framework/
|
||||
- **`AgentMiddleware`** - Intercepts agent `run()` calls
|
||||
- **`ChatMiddleware`** - Intercepts chat client `get_response()` calls
|
||||
- **`FunctionMiddleware`** - Intercepts function/tool invocations
|
||||
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware
|
||||
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration).
|
||||
|
||||
### Sessions (`_sessions.py`)
|
||||
|
||||
@@ -76,6 +76,19 @@ 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, MCPWebsocketTool
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool
|
||||
from ._middleware import (
|
||||
AgentContext,
|
||||
AgentMiddleware,
|
||||
@@ -168,6 +168,9 @@ from ._skills import (
|
||||
InlineSkillResource,
|
||||
InlineSkillScript,
|
||||
InMemorySkillsSource,
|
||||
MCPSkill,
|
||||
MCPSkillResource,
|
||||
MCPSkillsSource,
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
SkillResource,
|
||||
@@ -441,8 +444,12 @@ __all__ = [
|
||||
"InlineSkillResource",
|
||||
"InlineSkillScript",
|
||||
"LocalEvaluator",
|
||||
"MCPSkill",
|
||||
"MCPSkillResource",
|
||||
"MCPSkillsSource",
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPTaskOptions",
|
||||
"MCPWebsocketTool",
|
||||
"MemoryContextProvider",
|
||||
"MemoryFileStore",
|
||||
|
||||
@@ -380,8 +380,15 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
|
||||
return prepared_messages
|
||||
from ._compaction import apply_compaction
|
||||
|
||||
# Compact the caller's list in place when possible. A compaction operation has
|
||||
# two halves: exclusion flags (mutated on shared Message objects) and inserted
|
||||
# summary messages. Operating on the original list keeps both halves on the list
|
||||
# the function-invocation tool loop reuses across iterations; otherwise inserted
|
||||
# summaries would be lost on a throwaway copy while exclusions persisted, silently
|
||||
# dropping older groups (issue #4991).
|
||||
working_messages = messages if isinstance(messages, list) else prepared_messages
|
||||
return await apply_compaction(
|
||||
prepared_messages,
|
||||
working_messages,
|
||||
strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
@@ -92,10 +92,23 @@ def _is_reasoning_only_assistant(message: Message) -> bool:
|
||||
return all(content.type == "text_reasoning" for content in message.contents)
|
||||
|
||||
|
||||
def _ensure_message_ids(messages: list[Message]) -> None:
|
||||
def _ensure_message_ids(
|
||||
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
|
||||
) -> None:
|
||||
existing_ids: set[str] = set(reserved_ids) if reserved_ids is not None else set()
|
||||
existing_ids.update(message.message_id for message in messages if message.message_id)
|
||||
for index, message in enumerate(messages):
|
||||
if not message.message_id:
|
||||
message.message_id = f"msg_{index}"
|
||||
if message.message_id:
|
||||
continue
|
||||
candidate = f"msg_{id_offset + index}"
|
||||
if candidate in existing_ids:
|
||||
counter = id_offset + len(messages)
|
||||
candidate = f"msg_{counter}"
|
||||
while candidate in existing_ids:
|
||||
counter += 1
|
||||
candidate = f"msg_{counter}"
|
||||
message.message_id = candidate
|
||||
existing_ids.add(candidate)
|
||||
|
||||
|
||||
def _group_id_for(message: Message, group_index: int) -> str:
|
||||
@@ -104,14 +117,27 @@ def _group_id_for(message: Message, group_index: int) -> str:
|
||||
return f"group_index_{group_index}"
|
||||
|
||||
|
||||
def group_messages(messages: list[Message]) -> list[dict[str, Any]]:
|
||||
def group_messages(
|
||||
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compute group spans and metadata for annotation.
|
||||
|
||||
Args:
|
||||
messages: The messages (or a slice of them) to group.
|
||||
|
||||
Keyword Args:
|
||||
id_offset: Absolute starting index used when auto-assigning ``message_id``
|
||||
values, so incremental annotation of a list slice produces ids that
|
||||
stay unique across the full list.
|
||||
reserved_ids: Message ids that already exist outside ``messages`` (for
|
||||
example in a preserved prefix). Auto-assigned ids are guaranteed not
|
||||
to collide with these, preventing duplicate ids across the full list.
|
||||
|
||||
Returns:
|
||||
Ordered list of lightweight span dicts with keys:
|
||||
``group_id``, ``kind``, ``start_index``, ``end_index``, ``has_reasoning``.
|
||||
"""
|
||||
_ensure_message_ids(messages)
|
||||
_ensure_message_ids(messages, id_offset=id_offset, reserved_ids=reserved_ids)
|
||||
spans: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
group_index = 0
|
||||
@@ -439,7 +465,8 @@ def annotate_message_groups(
|
||||
if previous_group_index is not None:
|
||||
group_index_offset = previous_group_index + 1
|
||||
|
||||
spans = group_messages(messages[start_index:])
|
||||
reserved_ids = {message.message_id for message in messages[:start_index] if message.message_id}
|
||||
spans = group_messages(messages[start_index:], id_offset=start_index, reserved_ids=reserved_ids)
|
||||
for span_index, span in enumerate(spans):
|
||||
group_id = str(span["group_id"])
|
||||
kind = _coerce_group_kind(span["kind"])
|
||||
|
||||
@@ -58,6 +58,9 @@ 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,6 +349,8 @@ class BackgroundAgentsProvider(ContextProvider):
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Background task {task_id} started on agent '{agent_name}'."
|
||||
|
||||
background_agents_start_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
|
||||
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
|
||||
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
|
||||
@@ -471,6 +473,8 @@ class BackgroundAgentsProvider(ContextProvider):
|
||||
_save_provider_state(session, provider_state, source_id=source_id)
|
||||
return f"Task {task_id} continued with new input."
|
||||
|
||||
background_agents_continue_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@tool(name="background_agents_clear_completed_task", approval_mode="never_require")
|
||||
def background_agents_clear_completed_task(task_id: int) -> str:
|
||||
"""Remove a completed or failed task and release its session to free memory."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast, overload
|
||||
|
||||
from ._clients import SupportsChatGetResponse
|
||||
from ._feature_stage import ExperimentalFeature, experimental
|
||||
from ._types import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
@@ -214,6 +215,12 @@ class FunctionInvocationContext:
|
||||
result: Function execution result. Can be observed after calling ``call_next()``
|
||||
to see the actual execution result or can be set to override the execution result.
|
||||
kwargs: Additional runtime keyword arguments forwarded to the function invocation.
|
||||
tools: The live, mutable list of tools available to the model for the current
|
||||
agent run, or ``None`` when the function is invoked outside of a
|
||||
function-calling loop (for example via ``FunctionTool.invoke`` directly).
|
||||
Tools can add or remove tools during execution using :meth:`add_tools`
|
||||
and :meth:`remove_tools` (progressive tool exposure). Mutations take
|
||||
effect on the **next** model iteration, not the in-flight batch.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -232,6 +239,18 @@ class FunctionInvocationContext:
|
||||
|
||||
# Continue execution
|
||||
await call_next()
|
||||
|
||||
Progressive tool exposure from inside a tool:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import FunctionInvocationContext, tool
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def load_math_tools(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools([factorial, fibonacci])
|
||||
return "Math tools are now available."
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -242,6 +261,7 @@ class FunctionInvocationContext:
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
result: Any = None,
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
tools: list[ToolTypes] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the FunctionInvocationContext.
|
||||
|
||||
@@ -252,6 +272,9 @@ class FunctionInvocationContext:
|
||||
metadata: Metadata dictionary for sharing data between function middleware.
|
||||
result: Function execution result.
|
||||
kwargs: Additional runtime keyword arguments forwarded to the function invocation.
|
||||
tools: The live, mutable list of tools for the current agent run. When provided,
|
||||
this is the same list object the model sees on the next iteration, so
|
||||
appending or removing tools changes the model's available tools.
|
||||
"""
|
||||
self.function = function
|
||||
self.arguments = arguments
|
||||
@@ -259,6 +282,96 @@ class FunctionInvocationContext:
|
||||
self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {}
|
||||
self.result = result
|
||||
self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {}
|
||||
self.tools = tools
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.PROGRESSIVE_TOOLS)
|
||||
def add_tools(
|
||||
self,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]],
|
||||
) -> None:
|
||||
"""Add one or more tools to the current agent run (progressive tool exposure).
|
||||
|
||||
Callable inputs are converted to :class:`FunctionTool`, and tool collections are
|
||||
flattened, using the same normalization as the rest of the framework. Added tools
|
||||
become available to the model on the **next** iteration of the function-calling
|
||||
loop; they do not affect tool calls already requested in the in-flight batch.
|
||||
|
||||
Adding a tool whose name already exists is a no-op when it is the same object, and
|
||||
raises ``ValueError`` when it is a different object with a duplicate name.
|
||||
|
||||
Args:
|
||||
tools: A single tool/callable or a sequence of tools/callables to add.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the context has no live tools list (for example when the
|
||||
function is invoked outside of a function-calling loop).
|
||||
ValueError: If a different tool with a duplicate name is added.
|
||||
"""
|
||||
from ._tools import _append_unique_tools, normalize_tools # type: ignore[reportPrivateUsage]
|
||||
|
||||
if self.tools is None:
|
||||
raise RuntimeError(
|
||||
"Cannot add tools: this FunctionInvocationContext is not bound to a live "
|
||||
"agent run. add_tools is only available for functions invoked within an "
|
||||
"agent's function-calling loop."
|
||||
)
|
||||
# Validate the whole batch against a throwaway copy first, so a duplicate-name
|
||||
# clash partway through the batch raises before the live tool list is mutated
|
||||
# (all-or-nothing semantics).
|
||||
merged = _append_unique_tools(list(self.tools), normalize_tools(tools))
|
||||
self.tools[:] = merged
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.PROGRESSIVE_TOOLS)
|
||||
def remove_tools(
|
||||
self,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | str | Sequence[str],
|
||||
) -> None:
|
||||
"""Remove one or more tools from the current agent run (progressive tool exposure).
|
||||
|
||||
Tools may be specified by name, by tool object, or by the original callable. Names
|
||||
that are not currently present are ignored. Removals take effect on the **next**
|
||||
iteration of the function-calling loop; tool calls already requested in the
|
||||
in-flight batch still execute.
|
||||
|
||||
Args:
|
||||
tools: A tool name, tool/callable, or a sequence of any of these to remove.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the context has no live tools list (for example when the
|
||||
function is invoked outside of a function-calling loop).
|
||||
"""
|
||||
from ._tools import _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage]
|
||||
|
||||
if self.tools is None:
|
||||
raise RuntimeError(
|
||||
"Cannot remove tools: this FunctionInvocationContext is not bound to a live "
|
||||
"agent run. remove_tools is only available for functions invoked within an "
|
||||
"agent's function-calling loop."
|
||||
)
|
||||
|
||||
names_to_remove: set[str] = set()
|
||||
raw_items: list[Any]
|
||||
if isinstance(tools, str):
|
||||
raw_items = [tools]
|
||||
elif isinstance(tools, Sequence) and not isinstance(tools, (bytes, bytearray)):
|
||||
raw_items = list(cast("Sequence[Any]", tools))
|
||||
else:
|
||||
raw_items = [tools]
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
names_to_remove.add(item)
|
||||
continue
|
||||
for normalized in normalize_tools(item):
|
||||
if name := _get_tool_name(normalized): # type: ignore[reportPrivateUsage]
|
||||
names_to_remove.add(name)
|
||||
|
||||
if not names_to_remove:
|
||||
return
|
||||
self.tools[:] = [
|
||||
tool
|
||||
for tool in self.tools
|
||||
if _get_tool_name(tool) not in names_to_remove # type: ignore[reportPrivateUsage]
|
||||
]
|
||||
|
||||
|
||||
class ChatContext:
|
||||
|
||||
@@ -44,6 +44,7 @@ Only use skills from trusted sources.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -60,6 +61,10 @@ from ._sessions import ContextProvider
|
||||
from ._tools import FunctionTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.types import ReadResourceResult
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from ._agents import SupportsAgentRun
|
||||
from ._sessions import AgentSession, SessionContext
|
||||
|
||||
@@ -3285,4 +3290,443 @@ class AggregatingSkillsSource(SkillsSource):
|
||||
return result
|
||||
|
||||
|
||||
# region MCP Skills
|
||||
|
||||
|
||||
def _mcp_any_url(uri: str) -> AnyUrl:
|
||||
"""Convert a string URI to a :class:`pydantic.AnyUrl` for MCP client calls."""
|
||||
from pydantic import AnyUrl as _AnyUrl
|
||||
|
||||
return _AnyUrl(uri)
|
||||
|
||||
|
||||
def _is_mcp_resource_not_found(ex: Exception) -> bool:
|
||||
"""Return ``True`` when *ex* is an :class:`McpError` indicating a missing resource.
|
||||
|
||||
Two codes are treated as "not found":
|
||||
|
||||
* ``-32002`` — the MCP-spec "Resource not found" code returned by a
|
||||
compliant server when the URI does not exist. Not exported as a
|
||||
constant from ``mcp.types`` but defined by the resources subprotocol.
|
||||
* ``METHOD_NOT_FOUND`` (``-32601``) — the server does not implement
|
||||
``resources/read`` at all, which for the skills source is functionally
|
||||
equivalent to "no skills available."
|
||||
|
||||
All other codes — ``INVALID_PARAMS``, ``INTERNAL_ERROR``, ``PARSE_ERROR``,
|
||||
``CONNECTION_CLOSED``, auth rejections, and generic handler errors
|
||||
(code ``0``) — are treated as real failures so that a misconfigured
|
||||
token or crashing server is not silently mistaken for "the server has no
|
||||
skills."
|
||||
"""
|
||||
from mcp.shared.exceptions import McpError as _McpError
|
||||
|
||||
if not isinstance(ex, _McpError):
|
||||
return False
|
||||
from mcp.types import METHOD_NOT_FOUND as _METHOD_NOT_FOUND
|
||||
|
||||
return ex.error.code in {-32002, _METHOD_NOT_FOUND}
|
||||
|
||||
|
||||
def _mcp_join_text(result: ReadResourceResult) -> str:
|
||||
"""Join all :class:`TextResourceContents` items in a result into a single string."""
|
||||
from mcp.types import TextResourceContents as _TextResourceContents
|
||||
|
||||
return "\n".join(c.text for c in result.contents if isinstance(c, _TextResourceContents))
|
||||
|
||||
|
||||
class _McpSkillIndexEntry: # noqa: B903
|
||||
"""A single entry in the ``skill://index.json`` discovery document.
|
||||
|
||||
All fields are optional to support lenient deserialization; callers
|
||||
validate required fields before use.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
type: str | None = None,
|
||||
description: str | None = None,
|
||||
url: str | None = None,
|
||||
digest: str | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.url = url
|
||||
self.digest = digest
|
||||
|
||||
|
||||
class _McpSkillIndex:
|
||||
"""DTO for the ``skill://index.json`` discovery document.
|
||||
|
||||
Represents the Agent Skills Discovery v0.2.0 schema as bound to MCP
|
||||
by SEP-2640.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
schema: str | None = None,
|
||||
skills: list[_McpSkillIndexEntry] | None = None,
|
||||
) -> None:
|
||||
self.schema = schema
|
||||
self.skills: list[_McpSkillIndexEntry] = skills if skills is not None else []
|
||||
|
||||
|
||||
def _parse_mcp_skill_index(text: str) -> _McpSkillIndex:
|
||||
"""Parse a JSON string into a :class:`_McpSkillIndex`.
|
||||
|
||||
Args:
|
||||
text: Raw JSON text from ``skill://index.json``.
|
||||
|
||||
Returns:
|
||||
A populated :class:`_McpSkillIndex` instance.
|
||||
|
||||
Raises:
|
||||
json.JSONDecodeError: If the text is not valid JSON.
|
||||
ValueError: If the top-level value is not a JSON object.
|
||||
"""
|
||||
raw: dict[str, Any] = json.loads(text)
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("skill://index.json must be a JSON object")
|
||||
|
||||
entries: list[_McpSkillIndexEntry] = []
|
||||
|
||||
raw_skills: list[Any] = raw.get("skills") or []
|
||||
|
||||
for item in raw_skills:
|
||||
if isinstance(item, dict):
|
||||
d = cast(dict[str, Any], item)
|
||||
|
||||
entries.append(
|
||||
_McpSkillIndexEntry(
|
||||
name=d.get("name"),
|
||||
type=d.get("type"),
|
||||
description=d.get("description"),
|
||||
url=d.get("url"),
|
||||
digest=d.get("digest"),
|
||||
)
|
||||
)
|
||||
|
||||
return _McpSkillIndex(schema=raw.get("$schema"), skills=entries)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
|
||||
class MCPSkillResource(SkillResource):
|
||||
"""A :class:`SkillResource` backed by content fetched from an MCP server.
|
||||
|
||||
The :class:`~mcp.types.ReadResourceResult` is fetched eagerly by
|
||||
:meth:`MCPSkill.get_resource` at construction time; :meth:`read`
|
||||
extracts text or binary content from the result.
|
||||
"""
|
||||
|
||||
def __init__(self, *, name: str, result: ReadResourceResult) -> None:
|
||||
"""Initialize an MCPSkillResource.
|
||||
|
||||
Args:
|
||||
name: The resource name (e.g. a relative path or identifier).
|
||||
result: The result returned by the MCP server's ``resources/read`` request.
|
||||
"""
|
||||
super().__init__(name=name)
|
||||
self._result = result
|
||||
|
||||
async def read(self, **kwargs: Any) -> Any:
|
||||
"""Read the resource content.
|
||||
|
||||
Returns:
|
||||
A ``bytes`` object when the resource contains binary content,
|
||||
a ``str`` when it contains text, or ``None`` when the server
|
||||
returned no content blocks.
|
||||
"""
|
||||
from mcp.types import BlobResourceContents, TextResourceContents
|
||||
|
||||
for content in self._result.contents:
|
||||
if isinstance(content, BlobResourceContents):
|
||||
blob = content.blob
|
||||
# Strip data-URI prefix if present (some MCP servers send
|
||||
# full data URIs instead of raw base64).
|
||||
if blob.startswith("data:"):
|
||||
blob = blob.split(",", 1)[-1]
|
||||
return base64.b64decode(blob)
|
||||
|
||||
text = "\n".join(c.text for c in self._result.contents if isinstance(c, TextResourceContents))
|
||||
return text if text else None
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
|
||||
class MCPSkill(Skill):
|
||||
"""A :class:`Skill` discovered from an MCP server exposing the Agent Skills convention.
|
||||
|
||||
The skill is constructed from ``skill://index.json`` discovery metadata;
|
||||
:meth:`get_content` fetches the full ``SKILL.md`` content from the MCP
|
||||
server on demand via ``resources/read``.
|
||||
|
||||
Per SEP-2640, resources referenced inside SKILL.md are fetched on demand
|
||||
via the originating MCP server: :meth:`get_resource` resolves a relative
|
||||
resource name against the skill's root URI, issues a ``resources/read``
|
||||
request, and returns an :class:`MCPSkillResource` with pre-fetched content.
|
||||
"""
|
||||
|
||||
_SKILL_MD_SUFFIX: Final[str] = "SKILL.md"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
frontmatter: SkillFrontmatter,
|
||||
skill_md_uri: str,
|
||||
client: ClientSession,
|
||||
) -> None:
|
||||
"""Initialize an MCPSkill.
|
||||
|
||||
Args:
|
||||
frontmatter: The parsed frontmatter metadata for this skill.
|
||||
skill_md_uri: The full MCP resource URI of the ``SKILL.md`` resource
|
||||
(e.g. ``skill://unit-converter/SKILL.md``). The skill's root URI
|
||||
is derived by stripping the trailing ``SKILL.md`` segment.
|
||||
client: The MCP client session used to fetch resources on demand.
|
||||
"""
|
||||
self._frontmatter = frontmatter
|
||||
self._skill_md_uri = skill_md_uri
|
||||
self._skill_root_uri = self._compute_skill_root_uri(skill_md_uri)
|
||||
self._client = client
|
||||
self._content: str | None = None
|
||||
|
||||
@property
|
||||
def frontmatter(self) -> SkillFrontmatter:
|
||||
"""The L1 discovery metadata for this skill."""
|
||||
return self._frontmatter
|
||||
|
||||
async def get_content(self) -> str:
|
||||
"""Get the full SKILL.md content from the MCP server.
|
||||
|
||||
Fetches the content via ``resources/read`` on the first call and
|
||||
caches the result for subsequent calls.
|
||||
|
||||
Returns:
|
||||
The SKILL.md content string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the MCP server returned no text content for the
|
||||
SKILL.md resource.
|
||||
"""
|
||||
if self._content is not None:
|
||||
return self._content
|
||||
|
||||
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
|
||||
text = _mcp_join_text(result)
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
|
||||
)
|
||||
self._content = text
|
||||
return text
|
||||
|
||||
async def get_resource(self, name: str) -> SkillResource | None:
|
||||
"""Get a sibling resource by name from the MCP server.
|
||||
|
||||
Resolves *name* as a relative path against the skill's root URI,
|
||||
issues a ``resources/read`` request to the MCP server, and returns
|
||||
an :class:`MCPSkillResource` with the pre-fetched content.
|
||||
|
||||
Args:
|
||||
name: The resource name (e.g. ``references/checklist.md``).
|
||||
|
||||
Returns:
|
||||
An :class:`MCPSkillResource`, or ``None`` when the name is empty
|
||||
or the resource does not exist on the server.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return None
|
||||
|
||||
normalized = self._validate_resource_name(name)
|
||||
if normalized is None:
|
||||
return None
|
||||
|
||||
uri = self._skill_root_uri + normalized
|
||||
try:
|
||||
result = await self._client.read_resource(_mcp_any_url(uri))
|
||||
except Exception as ex:
|
||||
if _is_mcp_resource_not_found(ex):
|
||||
logger.debug("MCP resource '%s' not available: %s", uri, ex)
|
||||
return None
|
||||
raise
|
||||
|
||||
return MCPSkillResource(name=name, result=result)
|
||||
|
||||
@staticmethod
|
||||
def _validate_resource_name(name: str) -> str | None:
|
||||
"""Validate a resource name and return the normalized form.
|
||||
|
||||
Defense in depth: refuses names that could escape the skill root
|
||||
(absolute paths, embedded URI schemes, parent-traversal segments).
|
||||
The MCP server is the authority on URI resolution, but rejecting
|
||||
obviously unsafe shapes client-side avoids leaking escape attempts
|
||||
upstream.
|
||||
|
||||
Args:
|
||||
name: The raw resource name to validate.
|
||||
|
||||
Returns:
|
||||
The normalized name with backslashes replaced by forward slashes,
|
||||
or ``None`` if the name is unsafe.
|
||||
"""
|
||||
normalized = name.replace("\\", "/")
|
||||
if (
|
||||
normalized.startswith("/")
|
||||
or "://" in normalized
|
||||
or any(seg == ".." for seg in normalized.split("/"))
|
||||
):
|
||||
logger.debug("Rejecting resource name with unsafe path components: %r", name)
|
||||
return None
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _compute_skill_root_uri(skill_md_uri: str) -> str:
|
||||
"""Strip the trailing ``SKILL.md`` from the URI to produce the skill root.
|
||||
|
||||
If the URI doesn't end with ``SKILL.md``, ensures it ends with a
|
||||
trailing slash.
|
||||
"""
|
||||
if skill_md_uri.endswith(MCPSkill._SKILL_MD_SUFFIX):
|
||||
return skill_md_uri[: -len(MCPSkill._SKILL_MD_SUFFIX)]
|
||||
if skill_md_uri.endswith("/"):
|
||||
return skill_md_uri
|
||||
return skill_md_uri + "/"
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.MCP_SKILLS)
|
||||
class MCPSkillsSource(SkillsSource):
|
||||
"""A :class:`SkillsSource` that discovers Agent Skills served over MCP.
|
||||
|
||||
Discovery follows the SEP-2640 recommended approach: the source reads
|
||||
the well-known ``skill://index.json`` resource and constructs one
|
||||
:class:`MCPSkill` per ``skill-md`` entry directly from the entry's
|
||||
``name``, ``description``, and ``url`` fields.
|
||||
|
||||
The referenced ``SKILL.md`` resource is **not** read during discovery;
|
||||
the host fetches its body on demand via ``resources/read`` when the
|
||||
skill content is needed.
|
||||
|
||||
Only index entries of type ``skill-md`` are supported; entries of any
|
||||
other type are silently skipped.
|
||||
|
||||
If ``skill://index.json`` is absent, unreadable, empty, or fails to
|
||||
parse, this source returns an empty list.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
source = MCPSkillsSource(client=session)
|
||||
skills = await source.get_skills()
|
||||
"""
|
||||
|
||||
_INDEX_URI: Final[str] = "skill://index.json"
|
||||
_SKILL_MD_TYPE: Final[str] = "skill-md"
|
||||
|
||||
def __init__(self, client: ClientSession) -> None:
|
||||
"""Initialize an MCPSkillsSource.
|
||||
|
||||
Args:
|
||||
client: An MCP client session connected to a server that
|
||||
exposes Agent Skills resources.
|
||||
"""
|
||||
self._client = client
|
||||
|
||||
async def get_skills(self) -> list[Skill]:
|
||||
"""Discover and return skills from the MCP server.
|
||||
|
||||
Reads ``skill://index.json``, parses it, and creates an
|
||||
:class:`MCPSkill` for each valid ``skill-md`` entry.
|
||||
|
||||
Returns:
|
||||
A list of discovered :class:`MCPSkill` instances.
|
||||
"""
|
||||
index = await self._try_read_index()
|
||||
if index is None:
|
||||
return []
|
||||
|
||||
skills: list[Skill] = []
|
||||
for entry in index.skills:
|
||||
result = self._try_create_skill(entry)
|
||||
if result is not None:
|
||||
skills.append(result)
|
||||
logger.info("Loaded MCP skill: %s", result.frontmatter.name)
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping skill index entry '%s'",
|
||||
entry.name or "(unnamed)",
|
||||
)
|
||||
|
||||
logger.info("Successfully loaded %d skills from MCP server", len(skills))
|
||||
return skills
|
||||
|
||||
async def _try_read_index(self) -> _McpSkillIndex | None:
|
||||
"""Attempt to read and parse ``skill://index.json`` from the MCP server.
|
||||
|
||||
Returns:
|
||||
A parsed :class:`_McpSkillIndex`, or ``None`` if the index is
|
||||
absent, empty, or malformed.
|
||||
"""
|
||||
try:
|
||||
result = await self._client.read_resource(_mcp_any_url(self._INDEX_URI))
|
||||
except Exception as ex:
|
||||
if _is_mcp_resource_not_found(ex):
|
||||
logger.debug("No skill://index.json resource available on MCP server: %s", ex)
|
||||
return None
|
||||
logger.warning("Failed to read skill://index.json from MCP server.", exc_info=True)
|
||||
raise
|
||||
|
||||
index_text = _mcp_join_text(result)
|
||||
if not index_text:
|
||||
logger.debug("skill://index.json on MCP server returned empty/non-text contents")
|
||||
return None
|
||||
|
||||
try:
|
||||
return _parse_mcp_skill_index(index_text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.warning("Failed to parse skill://index.json JSON document.", exc_info=True)
|
||||
return None
|
||||
|
||||
def _try_create_skill(self, entry: _McpSkillIndexEntry) -> MCPSkill | None:
|
||||
"""Attempt to create an :class:`MCPSkill` from an index entry.
|
||||
|
||||
Args:
|
||||
entry: A single entry from the skill index.
|
||||
|
||||
Returns:
|
||||
An :class:`MCPSkill` if the entry is valid, or ``None`` if the
|
||||
entry should be skipped.
|
||||
"""
|
||||
if entry.type != self._SKILL_MD_TYPE:
|
||||
logger.debug(
|
||||
"Skipping entry '%s': unsupported type '%s'",
|
||||
entry.name or "(unnamed)",
|
||||
entry.type or "(none)",
|
||||
)
|
||||
return None
|
||||
|
||||
if not entry.name or not entry.name.strip():
|
||||
logger.debug("Skipping entry: missing required 'name' field")
|
||||
return None
|
||||
|
||||
if not entry.description or not entry.description.strip():
|
||||
logger.debug("Skipping entry '%s': missing required 'description' field", entry.name)
|
||||
return None
|
||||
|
||||
if not entry.url or not entry.url.strip():
|
||||
logger.debug("Skipping entry '%s': missing required 'url' field", entry.name)
|
||||
return None
|
||||
|
||||
try:
|
||||
fm = SkillFrontmatter(name=entry.name, description=entry.description)
|
||||
except ValueError as ex:
|
||||
logger.debug("Skipping entry '%s': invalid metadata: %s", entry.name, ex)
|
||||
return None
|
||||
|
||||
return MCPSkill(frontmatter=fm, skill_md_uri=entry.url, client=self._client)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -292,6 +292,7 @@ class FunctionTool(SerializationMixin):
|
||||
"_cached_parameters",
|
||||
"_input_schema",
|
||||
"_schema_supplied",
|
||||
"_invoke_sync_on_event_loop",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -366,6 +367,7 @@ class FunctionTool(SerializationMixin):
|
||||
self.description = description
|
||||
self.kind = kind
|
||||
self.additional_properties = additional_properties
|
||||
self._invoke_sync_on_event_loop = False
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
@@ -537,6 +539,16 @@ class FunctionTool(SerializationMixin):
|
||||
self.invocation_exception_count += 1
|
||||
raise
|
||||
|
||||
async def _invoke_function(self, call_kwargs: Mapping[str, Any]) -> Any:
|
||||
"""Run sync tools off the event loop during async invocation."""
|
||||
func = self.func.func if isinstance(self.func, FunctionTool) else self.func
|
||||
if inspect.iscoroutinefunction(func) or getattr(self, "_invoke_sync_on_event_loop", False):
|
||||
res = self.__call__(**call_kwargs)
|
||||
return await res if inspect.isawaitable(res) else res
|
||||
|
||||
res = await asyncio.to_thread(self.__call__, **call_kwargs)
|
||||
return await res if inspect.isawaitable(res) else res
|
||||
|
||||
@overload
|
||||
async def invoke(
|
||||
self,
|
||||
@@ -679,8 +691,7 @@ class FunctionTool(SerializationMixin):
|
||||
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
|
||||
logger.info(f"Function name: {self.name}")
|
||||
logger.debug(f"Function arguments: {observable_kwargs}")
|
||||
res = self.__call__(**call_kwargs)
|
||||
result = await res if inspect.isawaitable(res) else res
|
||||
result = await self._invoke_function(call_kwargs)
|
||||
if skip_parsing:
|
||||
logger.info(f"Function {self.name} succeeded.")
|
||||
logger.debug(f"Function result: {type(result).__name__}")
|
||||
@@ -730,8 +741,7 @@ class FunctionTool(SerializationMixin):
|
||||
start_time_stamp = perf_counter()
|
||||
end_time_stamp: float | None = None
|
||||
try:
|
||||
res = self.__call__(**call_kwargs)
|
||||
result = await res if inspect.isawaitable(res) else res
|
||||
result = await self._invoke_function(call_kwargs)
|
||||
end_time_stamp = perf_counter()
|
||||
except Exception as exception:
|
||||
end_time_stamp = perf_counter()
|
||||
@@ -1418,6 +1428,7 @@ async def _auto_invoke_function(
|
||||
sequence_index: int | None = None,
|
||||
request_index: int | None = None,
|
||||
middleware_pipeline: FunctionMiddlewarePipeline | None = None,
|
||||
live_tools: list[ToolTypes] | None = None,
|
||||
) -> Content:
|
||||
"""Invoke a function call requested by the agent, applying middleware that is defined.
|
||||
|
||||
@@ -1432,6 +1443,8 @@ async def _auto_invoke_function(
|
||||
sequence_index: The index of the function call in the sequence.
|
||||
request_index: The index of the request iteration.
|
||||
middleware_pipeline: Optional middleware pipeline to apply during execution.
|
||||
live_tools: The live, mutable tools list for the current agent run, exposed on
|
||||
the FunctionInvocationContext so tools can add/remove tools at runtime.
|
||||
|
||||
Returns:
|
||||
The function result content.
|
||||
@@ -1523,6 +1536,7 @@ async def _auto_invoke_function(
|
||||
arguments=args,
|
||||
session=invocation_session,
|
||||
kwargs=runtime_kwargs.copy(),
|
||||
tools=live_tools,
|
||||
)
|
||||
function_result = await tool.invoke(
|
||||
arguments=args,
|
||||
@@ -1537,6 +1551,10 @@ async def _auto_invoke_function(
|
||||
except UserInputRequiredException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Function '{tool.name}' raised an exception; returning an error result to the "
|
||||
f"model. Set include_detailed_errors=True for the full detail. Exception: {exc!r}"
|
||||
)
|
||||
message = "Error: Function failed."
|
||||
if config.get("include_detailed_errors", False):
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1552,6 +1570,7 @@ async def _auto_invoke_function(
|
||||
arguments=args,
|
||||
session=invocation_session,
|
||||
kwargs=runtime_kwargs.copy(),
|
||||
tools=live_tools,
|
||||
)
|
||||
|
||||
call_id = function_call_content.call_id
|
||||
@@ -1608,6 +1627,10 @@ async def _auto_invoke_function(
|
||||
except UserInputRequiredException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Function '{tool.name}' raised an exception; returning an error result to the "
|
||||
f"model. Set include_detailed_errors=True for the full detail. Exception: {exc!r}"
|
||||
)
|
||||
message = "Error: Function failed."
|
||||
if config.get("include_detailed_errors", False):
|
||||
message = f"{message} Exception: {exc}"
|
||||
@@ -1659,6 +1682,9 @@ async def _try_execute_function_calls(
|
||||
from ._types import Content
|
||||
|
||||
tool_map = _get_tool_map(tools)
|
||||
# The live tools list (when tools is the run-local list) is exposed on the
|
||||
# FunctionInvocationContext so tools can add/remove tools during the run.
|
||||
live_tools: list[ToolTypes] | None = cast("list[ToolTypes]", tools) if isinstance(tools, list) else None
|
||||
approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"]
|
||||
logger.debug(
|
||||
"_try_execute_function_calls: tool_map keys=%s, approval_tools=%s",
|
||||
@@ -1733,6 +1759,7 @@ async def _try_execute_function_calls(
|
||||
request_index=attempt_idx,
|
||||
middleware_pipeline=middleware_pipeline,
|
||||
config=config,
|
||||
live_tools=live_tools,
|
||||
)
|
||||
return (result, False)
|
||||
except MiddlewareTermination as exc:
|
||||
@@ -2371,6 +2398,13 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=filtered_kwargs,
|
||||
)
|
||||
# Establish a single, run-local mutable tools list so that tools can add or remove
|
||||
# tools during the run (progressive tool exposure). A fresh list is created via
|
||||
# normalize_tools so the caller's original tools container is never mutated, while
|
||||
# the same list object is shared with the model (options["tools"]) and the tool map
|
||||
# rebuilt on every loop iteration.
|
||||
if mutable_options.get("tools"):
|
||||
mutable_options["tools"] = normalize_tools(mutable_options["tools"])
|
||||
if not stream:
|
||||
|
||||
async def _get_response() -> ChatResponse[Any]:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
@@ -12,7 +11,6 @@ from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload
|
||||
|
||||
from .._agents import BaseAgent
|
||||
from .._serialization import make_json_safe
|
||||
from .._sessions import (
|
||||
AgentSession,
|
||||
ContextProvider,
|
||||
@@ -30,11 +28,12 @@ from .._types import (
|
||||
UsageDetails,
|
||||
add_usage_details,
|
||||
)
|
||||
from ..exceptions import AgentInvalidRequestException, AgentInvalidResponseException
|
||||
from ..exceptions import AgentException, AgentInvalidRequestException, AgentInvalidResponseException
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._events import (
|
||||
AGENT_FORWARDED_EVENT_TYPES,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._typing_utils import is_instance_of, is_type_compatible
|
||||
@@ -59,27 +58,24 @@ class WorkflowAgent(BaseAgent):
|
||||
@dataclass
|
||||
class RequestInfoFunctionArgs:
|
||||
request_id: str
|
||||
data: Any
|
||||
request_event: WorkflowEvent
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"request_id": self.request_id, "data": make_json_safe(self.data)}
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict())
|
||||
return {"request_id": self.request_id, "request_event": self.request_event.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> WorkflowAgent.RequestInfoFunctionArgs:
|
||||
return cls(request_id=payload.get("request_id", ""), data=payload.get("data"))
|
||||
if "request_id" not in payload or "request_event" not in payload:
|
||||
raise ValueError(
|
||||
"Invalid payload for RequestInfoFunctionArgs. 'request_id' and 'request_event' are required."
|
||||
)
|
||||
if not payload["request_id"]:
|
||||
raise ValueError("request_id cannot be empty.")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw: str) -> WorkflowAgent.RequestInfoFunctionArgs:
|
||||
try:
|
||||
parsed: Any = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"RequestInfoFunctionArgs JSON payload is malformed: {exc}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("RequestInfoFunctionArgs JSON payload must decode to a mapping")
|
||||
return cls.from_dict(cast(dict[str, Any], parsed))
|
||||
return cls(
|
||||
request_id=payload.get("request_id", ""),
|
||||
request_event=WorkflowEvent.from_dict(payload.get("request_event", {})),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -129,16 +125,11 @@ class WorkflowAgent(BaseAgent):
|
||||
**kwargs,
|
||||
)
|
||||
self._workflow: Workflow = workflow
|
||||
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
|
||||
|
||||
@property
|
||||
def workflow(self) -> Workflow:
|
||||
return self._workflow
|
||||
|
||||
@property
|
||||
def pending_requests(self) -> dict[str, WorkflowEvent[Any]]:
|
||||
return self._pending_requests
|
||||
|
||||
# region Run Methods
|
||||
|
||||
@overload
|
||||
@@ -182,7 +173,7 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the workflow. Required for new runs,
|
||||
should be None when resuming from checkpoint.
|
||||
could be None if only restoring the underlying workflow from a checkpoint.
|
||||
|
||||
Keyword Args:
|
||||
stream: If True, returns an async iterable of updates. If False (default),
|
||||
@@ -416,101 +407,79 @@ class WorkflowAgent(BaseAgent):
|
||||
Yields:
|
||||
WorkflowEvent objects from the workflow execution.
|
||||
"""
|
||||
# Determine the execution mode based on state.
|
||||
# The streaming flag controls the workflow's internal streaming mode,
|
||||
# which affects executor behavior (e.g. AgentExecutor emits different event
|
||||
# types in streaming vs non-streaming mode).
|
||||
if bool(self.pending_requests):
|
||||
function_responses = self._process_pending_requests(input_messages)
|
||||
# Restore the workflow state if a checkpoint is provided
|
||||
if checkpoint_id is not None:
|
||||
if checkpoint_storage is None:
|
||||
raise AgentInvalidRequestException("checkpoint_storage must be provided when checkpoint_id is provided")
|
||||
logger.debug(f"Restoring workflow from checkpoint {checkpoint_id}")
|
||||
# Restore the workflow from checkpoint
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
responses=function_responses,
|
||||
stream=True,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
responses=function_responses,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
|
||||
elif checkpoint_id is not None:
|
||||
# Restore the prior workflow state from the checkpoint. Shared
|
||||
# state (e.g. accumulated conversation history maintained by the
|
||||
# workflow's executors) survives across turns because Workflow.run
|
||||
# no longer wipes state per call. Callers who want to deliver a
|
||||
# new user message after restore should make a second
|
||||
# `workflow.run(message=...)` call - they are NOT mutually
|
||||
# exclusive on the same instance, but each must be its own call.
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
async for _ in self.workflow.run(
|
||||
stream=True,
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
):
|
||||
pass
|
||||
else:
|
||||
_ = await self.workflow.run(
|
||||
checkpoint_id=checkpoint_id,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
if not input_messages:
|
||||
logger.info("No input messages provided; the workflow has been restored to the checkpoint state.")
|
||||
return
|
||||
|
||||
final_state = self._workflow.status
|
||||
logger.debug(f"Workflow state: {final_state}")
|
||||
|
||||
if final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Extract function responses from input messages, and ensure that
|
||||
# only function responses are present in messages if there is any
|
||||
# pending request.
|
||||
# NOTE: It is possible that some pending requests are not fulfilled,
|
||||
# and we will let the workflow to handle this -- the agent does not
|
||||
# have an opinion on this.
|
||||
function_responses = self._extract_function_responses(input_messages)
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
responses=function_responses,
|
||||
stream=True,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
checkpoint_id=checkpoint_id,
|
||||
responses=function_responses,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
elif final_state == WorkflowRunState.IDLE:
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
message=input_messages,
|
||||
stream=True,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
message=input_messages,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
|
||||
else:
|
||||
if streaming:
|
||||
async for event in self.workflow.run(
|
||||
message=input_messages,
|
||||
stream=True,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
else:
|
||||
for event in await self.workflow.run(
|
||||
message=input_messages,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
yield event
|
||||
raise AgentException(f"The underlying workflow is in an invalid state to restart: {final_state}.")
|
||||
|
||||
# endregion Run Methods
|
||||
|
||||
def _process_pending_requests(self, input_messages: Sequence[Message]) -> dict[str, Any]:
|
||||
"""Process pending requests by extracting function responses and updating state.
|
||||
|
||||
Args:
|
||||
input_messages: Input messages that may contain function responses.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their response data.
|
||||
"""
|
||||
logger.info(f"Continuing workflow to address {len(self.pending_requests)} requests")
|
||||
|
||||
# Extract function responses from input messages, and ensure that
|
||||
# only function responses are present in messages if there is any
|
||||
# pending request.
|
||||
function_responses = self._extract_function_responses(input_messages)
|
||||
|
||||
# Pop pending requests if fulfilled.
|
||||
for request_id in list(self.pending_requests.keys()):
|
||||
if request_id in function_responses:
|
||||
self.pending_requests.pop(request_id)
|
||||
|
||||
# NOTE: It is possible that some pending requests are not fulfilled,
|
||||
# and we will let the workflow to handle this -- the agent does not
|
||||
# have an opinion on this.
|
||||
return function_responses
|
||||
|
||||
def _convert_workflow_events_to_agent_response(
|
||||
self,
|
||||
response_id: str,
|
||||
@@ -528,10 +497,10 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
for output_event in output_events:
|
||||
if output_event.type == "request_info":
|
||||
function_call, approval_request = self._process_request_info_event(output_event)
|
||||
request_content = self._process_request_info_event(output_event)
|
||||
messages.append(
|
||||
Message(
|
||||
contents=[function_call, approval_request],
|
||||
contents=[request_content],
|
||||
role="assistant",
|
||||
author_name=output_event.source_executor_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
@@ -598,38 +567,6 @@ class WorkflowAgent(BaseAgent):
|
||||
raw_representation=raw_representations,
|
||||
)
|
||||
|
||||
def _process_request_info_event(
|
||||
self,
|
||||
event: WorkflowEvent[Any],
|
||||
) -> tuple[Content, Content]:
|
||||
"""Convert a request_info event to FunctionCallContent and FunctionApprovalRequestContent.
|
||||
|
||||
Args:
|
||||
event: A WorkflowEvent with type='request_info'.
|
||||
|
||||
Returns:
|
||||
A tuple of (FunctionCallContent, FunctionApprovalRequestContent).
|
||||
"""
|
||||
request_id = event.request_id
|
||||
if not request_id:
|
||||
raise ValueError("request_info event must have a request_id")
|
||||
|
||||
self.pending_requests[request_id] = event
|
||||
|
||||
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
|
||||
|
||||
function_call = Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=request_id,
|
||||
function_call=function_call,
|
||||
additional_properties={"request_id": request_id},
|
||||
)
|
||||
return function_call, approval_request
|
||||
|
||||
def _convert_workflow_event_to_agent_response_updates(
|
||||
self,
|
||||
response_id: str,
|
||||
@@ -731,85 +668,72 @@ class WorkflowAgent(BaseAgent):
|
||||
]
|
||||
|
||||
if event.type == "request_info":
|
||||
# Store the pending request for later correlation
|
||||
request_id = event.request_id
|
||||
if not request_id:
|
||||
raise ValueError("request_info event must have a request_id")
|
||||
|
||||
self.pending_requests[request_id] = event
|
||||
|
||||
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
|
||||
|
||||
function_call = Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=request_id,
|
||||
function_call=function_call,
|
||||
additional_properties={"request_id": request_id},
|
||||
)
|
||||
request_content = self._process_request_info_event(event)
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=[function_call, approval_request],
|
||||
contents=[request_content],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
response_id=response_id,
|
||||
message_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
raw_representation=event,
|
||||
)
|
||||
]
|
||||
|
||||
# Ignore workflow-internal events
|
||||
return []
|
||||
|
||||
def _process_request_info_event(
|
||||
self,
|
||||
event: WorkflowEvent[Any],
|
||||
) -> Content:
|
||||
"""Convert a request_info event to FunctionApprovalRequestContent.
|
||||
|
||||
Args:
|
||||
event: A WorkflowEvent with type='request_info'.
|
||||
|
||||
Returns:
|
||||
A content object representing the request info. The content can be a `function_approval_request`
|
||||
or a `function_call` depending on the structure of the event data.
|
||||
|
||||
Note:
|
||||
If the event data is already a FunctionApprovalRequestContent, it will be returned as-is.
|
||||
"""
|
||||
if isinstance(event.data, Content) and event.data.user_input_request:
|
||||
# Return the event data as-is if it's already a properly formed FunctionApprovalRequestContent
|
||||
return event.data
|
||||
|
||||
request_id = event.request_id
|
||||
args = self.RequestInfoFunctionArgs(request_id=request_id, request_event=event).to_dict()
|
||||
|
||||
return Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self.REQUEST_INFO_FUNCTION_NAME,
|
||||
arguments=args,
|
||||
)
|
||||
|
||||
def _extract_function_responses(self, input_messages: Sequence[Message]) -> dict[str, Any]:
|
||||
"""Extract function responses from input messages."""
|
||||
"""Extract function responses from input messages.
|
||||
|
||||
The responses are for pending requests that the workflow is waiting on, and
|
||||
will be passed to the workflow. The pending requests are processed to either
|
||||
`function_approval_request` or `function_call` content by `_process_request_info_event`.
|
||||
"""
|
||||
function_responses: dict[str, Any] = {}
|
||||
for message in input_messages:
|
||||
for content in message.contents:
|
||||
if content.type == "function_approval_response":
|
||||
# Parse the function arguments to recover request payload
|
||||
arguments_payload = content.function_call.arguments # type: ignore[attr-defined, union-attr]
|
||||
if isinstance(arguments_payload, str):
|
||||
try:
|
||||
parsed_args = self.RequestInfoFunctionArgs.from_json(arguments_payload)
|
||||
except ValueError as exc:
|
||||
raise AgentInvalidResponseException(
|
||||
"FunctionApprovalResponseContent arguments must decode to a mapping."
|
||||
) from exc
|
||||
elif isinstance(arguments_payload, dict):
|
||||
parsed_args = self.RequestInfoFunctionArgs.from_dict(arguments_payload)
|
||||
else:
|
||||
raise AgentInvalidResponseException(
|
||||
"FunctionApprovalResponseContent arguments must be a mapping or JSON string."
|
||||
)
|
||||
|
||||
request_id = parsed_args.request_id or content.id # type: ignore[attr-defined]
|
||||
if not content.approved: # type: ignore[attr-defined]
|
||||
raise AgentInvalidResponseException(f"Request '{request_id}' was not approved by the caller.")
|
||||
|
||||
if request_id in self.pending_requests:
|
||||
function_responses[request_id] = parsed_args.data
|
||||
elif bool(self.pending_requests):
|
||||
raise AgentInvalidRequestException(
|
||||
"Only responses for pending requests are allowed when there are outstanding approvals."
|
||||
)
|
||||
request_id: str = content.id # type: ignore[assignment]
|
||||
function_responses[request_id] = content
|
||||
elif content.type == "function_result":
|
||||
request_id = content.call_id # type: ignore[attr-defined]
|
||||
if request_id in self.pending_requests:
|
||||
response_data = content.result if hasattr(content, "result") else str(content) # type: ignore[attr-defined]
|
||||
function_responses[request_id] = response_data
|
||||
elif bool(self.pending_requests):
|
||||
raise AgentInvalidRequestException(
|
||||
"Only function responses for pending requests are allowed while requests are outstanding."
|
||||
)
|
||||
response_data = content.result if hasattr(content, "result") else str(content) # type: ignore[attr-defined]
|
||||
function_responses[content.call_id] = response_data # type: ignore
|
||||
else:
|
||||
if bool(self.pending_requests):
|
||||
raise AgentInvalidResponseException(
|
||||
"Unexpected content type while awaiting request info responses."
|
||||
)
|
||||
raise AgentInvalidResponseException(
|
||||
"Unexpected content type while awaiting request info responses."
|
||||
)
|
||||
|
||||
return function_responses
|
||||
|
||||
def _extract_contents(self, data: Any) -> list[Content]:
|
||||
|
||||
@@ -429,15 +429,30 @@ class AgentExecutor(Executor):
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
)
|
||||
await ctx.yield_output(response)
|
||||
|
||||
# Handle any user input requests
|
||||
if response.user_input_requests:
|
||||
user_input_request_count = len(response.user_input_requests)
|
||||
total_message_content_count = sum(len(msg.contents) for msg in response.messages)
|
||||
if user_input_request_count != total_message_content_count:
|
||||
logger.warning(
|
||||
"Response %s contains %d user input requests but total message contents are %d. "
|
||||
"This indicates the response contains both user input requests and message contents. "
|
||||
"Double check if this is the intended behavior, as non user input request contents in "
|
||||
"this response will not be emitted.",
|
||||
response.response_id,
|
||||
user_input_request_count,
|
||||
total_message_content_count,
|
||||
)
|
||||
for user_input_request in response.user_input_requests:
|
||||
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
|
||||
await ctx.request_info(user_input_request, Content)
|
||||
await ctx.request_info(user_input_request, Content, request_id=user_input_request.id)
|
||||
return None
|
||||
|
||||
# Only yield output if the response is complete and not waiting for user input.
|
||||
# This is to avoid emitting two events of different types ('output' and 'request_info')
|
||||
# that carry the same payload.
|
||||
await ctx.yield_output(response)
|
||||
return response
|
||||
|
||||
async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUpdate]) -> AgentResponse | None:
|
||||
@@ -472,9 +487,25 @@ class AgentExecutor(Executor):
|
||||
)
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await ctx.yield_output(update)
|
||||
if update.user_input_requests:
|
||||
user_input_request_count = len(update.user_input_requests)
|
||||
total_message_content_count = len(update.contents)
|
||||
if user_input_request_count != total_message_content_count:
|
||||
logger.warning(
|
||||
"Response update %s contains %d user input requests but total message contents are %d. "
|
||||
"This indicates the response update contains both user input requests and message contents. "
|
||||
"Double check if this is the intended behavior, as non user input request contents will "
|
||||
"not be emitted.",
|
||||
update.response_id,
|
||||
user_input_request_count,
|
||||
total_message_content_count,
|
||||
)
|
||||
streamed_user_input_requests.extend(update.user_input_requests)
|
||||
else:
|
||||
# Only yield output events for updates that do not contain user input requests.
|
||||
# This is to avoid emitting two events of different types ('output' and 'request_info')
|
||||
# that carry the same payload.
|
||||
await ctx.yield_output(update)
|
||||
|
||||
# Prefer stream finalization when available so result hooks run
|
||||
# (e.g., thread conversation updates). Fall back to reconstructing from updates
|
||||
@@ -509,7 +540,7 @@ class AgentExecutor(Executor):
|
||||
if user_input_requests:
|
||||
for user_input_request in user_input_requests:
|
||||
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
|
||||
await ctx.request_info(user_input_request, Content)
|
||||
await ctx.request_info(user_input_request, Content, request_id=user_input_request.id)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
@@ -360,6 +360,22 @@ class Workflow(DictConvertible):
|
||||
# Flag to prevent concurrent workflow executions
|
||||
self._is_running = False
|
||||
|
||||
# Current run-level status of this workflow instance. Updated in lockstep with
|
||||
# the status events emitted from `_run_workflow_with_tracing`. Defaults to IDLE
|
||||
# for a freshly built workflow that has not yet been run.
|
||||
self._status: WorkflowRunState = WorkflowRunState.IDLE
|
||||
|
||||
@property
|
||||
def status(self) -> WorkflowRunState:
|
||||
"""Return the current run-level status of this workflow instance.
|
||||
|
||||
Mirrors the most recent status event emitted by the workflow. Safe to read at
|
||||
any time: workflows run on a single asyncio event loop, and the underlying
|
||||
attribute is a single enum reference whose assignment is atomic under the
|
||||
CPython GIL, so no locking is required.
|
||||
"""
|
||||
return self._status
|
||||
|
||||
def _ensure_not_running(self) -> None:
|
||||
"""Ensure the workflow is not already running."""
|
||||
if self._is_running:
|
||||
@@ -513,8 +529,9 @@ class Workflow(DictConvertible):
|
||||
with _framework_event_origin():
|
||||
started = WorkflowEvent.started()
|
||||
yield started # noqa: RUF070
|
||||
self._status = WorkflowRunState.IN_PROGRESS
|
||||
with _framework_event_origin():
|
||||
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
|
||||
in_progress = WorkflowEvent.status(self._status)
|
||||
yield in_progress # noqa: RUF070
|
||||
|
||||
# Per-run reset for fresh-message runs only. We deliberately
|
||||
@@ -569,17 +586,20 @@ class Workflow(DictConvertible):
|
||||
|
||||
if event.type == "request_info" and not emitted_in_progress_pending:
|
||||
emitted_in_progress_pending = True
|
||||
self._status = WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
|
||||
with _framework_event_origin():
|
||||
pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
|
||||
pending_status = WorkflowEvent.status(self._status)
|
||||
yield pending_status # noqa: RUF070
|
||||
# Workflow runs until idle - emit final status based on whether requests are pending
|
||||
if saw_request:
|
||||
self._status = WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
with _framework_event_origin():
|
||||
terminal_status = WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
|
||||
terminal_status = WorkflowEvent.status(self._status)
|
||||
yield terminal_status
|
||||
else:
|
||||
self._status = WorkflowRunState.IDLE
|
||||
with _framework_event_origin():
|
||||
terminal_status = WorkflowEvent.status(WorkflowRunState.IDLE)
|
||||
terminal_status = WorkflowEvent.status(self._status)
|
||||
yield terminal_status
|
||||
|
||||
span.add_event(OtelAttr.WORKFLOW_COMPLETED)
|
||||
@@ -593,6 +613,7 @@ class Workflow(DictConvertible):
|
||||
with _framework_event_origin():
|
||||
failed_event = WorkflowEvent.failed(details)
|
||||
yield failed_event # noqa: RUF070
|
||||
self._status = WorkflowRunState.FAILED
|
||||
with _framework_event_origin():
|
||||
failed_status = WorkflowEvent.status(WorkflowRunState.FAILED)
|
||||
yield failed_status # noqa: RUF070
|
||||
|
||||
@@ -80,6 +80,7 @@ __all__ = [
|
||||
"EmbeddingTelemetryLayer",
|
||||
"OtelAttr",
|
||||
"configure_otel_providers",
|
||||
"create_mcp_client_span",
|
||||
"create_metric_views",
|
||||
"create_resource",
|
||||
"disable_instrumentation",
|
||||
@@ -87,6 +88,7 @@ __all__ = [
|
||||
"enable_sensitive_telemetry",
|
||||
"get_meter",
|
||||
"get_tracer",
|
||||
"set_mcp_span_error",
|
||||
]
|
||||
|
||||
|
||||
@@ -110,7 +112,6 @@ 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,
|
||||
@@ -292,6 +293,14 @@ 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"
|
||||
@@ -2013,6 +2022,61 @@ 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.
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -11,10 +11,14 @@ from agent_framework import (
|
||||
GROUP_TOKEN_COUNT_KEY,
|
||||
BaseChatClient,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
SlidingWindowStrategy,
|
||||
SupportsChatGetResponse,
|
||||
ToolResultCompactionStrategy,
|
||||
TruncationStrategy,
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
@@ -258,6 +262,196 @@ async def test_base_client_default_tokenizer_without_strategy_annotates_messages
|
||||
assert captured_token_counts == [[19, 19]]
|
||||
|
||||
|
||||
def _tool_call_response(call_id: str, location: str) -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id=call_id,
|
||||
name="lookup_weather",
|
||||
arguments=f'{{"location": "{location}"}}',
|
||||
)
|
||||
],
|
||||
),
|
||||
response_id=f"resp_{call_id}",
|
||||
)
|
||||
|
||||
|
||||
def _is_tool_result_summary(message: Message) -> bool:
|
||||
text = message.text or ""
|
||||
return message.role == "assistant" and text.startswith("[Tool results:")
|
||||
|
||||
|
||||
async def test_function_loop_persists_inserted_summaries_across_iterations(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
# Regression test for #4991: compaction inserts summary messages and excludes the
|
||||
# originals. Across tool-loop iterations the exclusion flags persisted (shared Message
|
||||
# objects) but the inserted summaries were dropped (they only lived on a throwaway copy),
|
||||
# so older tool groups were silently lost with no summary representing them.
|
||||
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
chat_client_base.run_responses = [ # type: ignore[attr-defined]
|
||||
_tool_call_response("call_1", "London"),
|
||||
_tool_call_response("call_2", "Paris"),
|
||||
_tool_call_response("call_3", "Tokyo"),
|
||||
]
|
||||
|
||||
captured_inputs: list[list[Message]] = []
|
||||
original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
|
||||
|
||||
async def _capture(
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
captured_inputs.append(list(messages))
|
||||
return await original(messages=messages, options=options, **kwargs)
|
||||
|
||||
chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
|
||||
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["What is the weather in London?"])],
|
||||
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
|
||||
)
|
||||
|
||||
# The final model call should represent every compacted tool group with a summary.
|
||||
# Two older tool groups get collapsed (London, Paris) while the last (Tokyo) is kept.
|
||||
final_input = captured_inputs[-1]
|
||||
summaries = [message for message in final_input if _is_tool_result_summary(message)]
|
||||
summary_text = " ".join(message.text or "" for message in summaries)
|
||||
|
||||
assert len(summaries) == 2, [message.text for message in final_input]
|
||||
assert "London" in summary_text
|
||||
assert "Paris" in summary_text
|
||||
|
||||
|
||||
def _tool_call_update(call_id: str, location: str) -> list[ChatResponseUpdate]:
|
||||
return [
|
||||
ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id=call_id,
|
||||
name="lookup_weather",
|
||||
arguments=f'{{"location": "{location}"}}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
response_id=f"resp_{call_id}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_function_loop_persists_inserted_summaries_across_iterations_streaming(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
# Streaming counterpart of the #4991 regression test: the summary persistence fix in
|
||||
# ``_prepare_messages_for_model_call`` must cover the streaming tool loop too.
|
||||
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
chat_client_base.streaming_responses = [ # type: ignore[attr-defined]
|
||||
_tool_call_update("call_1", "London"),
|
||||
_tool_call_update("call_2", "Paris"),
|
||||
_tool_call_update("call_3", "Tokyo"),
|
||||
]
|
||||
|
||||
captured_inputs: list[list[Message]] = []
|
||||
original = chat_client_base._get_streaming_response # type: ignore[attr-defined]
|
||||
|
||||
def _capture(
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
):
|
||||
captured_inputs.append(list(messages))
|
||||
return original(messages=messages, options=options, **kwargs)
|
||||
|
||||
chat_client_base._get_streaming_response = _capture # type: ignore[attr-defined,method-assign]
|
||||
|
||||
stream = chat_client_base.get_response(
|
||||
[Message(role="user", contents=["What is the weather in London?"])],
|
||||
stream=True,
|
||||
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
|
||||
)
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
final_input = captured_inputs[-1]
|
||||
summaries = [message for message in final_input if _is_tool_result_summary(message)]
|
||||
summary_text = " ".join(message.text or "" for message in summaries)
|
||||
|
||||
assert len(summaries) == 2, [message.text for message in final_input]
|
||||
assert "London" in summary_text
|
||||
assert "Paris" in summary_text
|
||||
|
||||
|
||||
async def test_function_loop_compaction_conversation_id_mode_does_not_resend_history(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
# In conversation-id mode the server owns prior context, so the tool loop clears
|
||||
# ``prepped_messages`` and only sends the latest message. Compaction must not fight that
|
||||
# by re-inserting summaries or re-sending earlier turns.
|
||||
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
|
||||
|
||||
@tool(name="lookup_weather", approval_mode="never_require")
|
||||
def lookup_weather(location: str) -> str:
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
def _conversation_tool_call(call_id: str, location: str) -> ChatResponse:
|
||||
response = _tool_call_response(call_id, location)
|
||||
response.conversation_id = "conv_1"
|
||||
return response
|
||||
|
||||
chat_client_base.run_responses = [ # type: ignore[attr-defined]
|
||||
_conversation_tool_call("call_1", "London"),
|
||||
_conversation_tool_call("call_2", "Paris"),
|
||||
_conversation_tool_call("call_3", "Tokyo"),
|
||||
]
|
||||
|
||||
captured_inputs: list[list[Message]] = []
|
||||
original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
|
||||
|
||||
async def _capture(
|
||||
*,
|
||||
messages: list[Message],
|
||||
options: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
captured_inputs.append(list(messages))
|
||||
return await original(messages=messages, options=options, **kwargs)
|
||||
|
||||
chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
|
||||
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["What is the weather in London?"])],
|
||||
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
|
||||
)
|
||||
|
||||
# After the conversation id is established the loop only forwards the latest message,
|
||||
# so subsequent model calls never receive the full history or summary messages.
|
||||
for sent in captured_inputs[1:]:
|
||||
assert len(sent) <= 1, [message.text for message in sent]
|
||||
assert not any(_is_tool_result_summary(message) for message in sent)
|
||||
|
||||
|
||||
def test_base_client_as_agent_does_not_copy_client_compaction_defaults(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
|
||||
@@ -196,6 +196,64 @@ def test_append_compaction_message_annotates_new_message() -> None:
|
||||
assert isinstance(_group_id(messages[1]), str)
|
||||
|
||||
|
||||
def test_incremental_annotation_assigns_unique_message_ids() -> None:
|
||||
# Regression test for #5237: ``_ensure_message_ids`` assigned ``msg_{index}``
|
||||
# using the position within the slice handed to ``group_messages``. Successive
|
||||
# incremental annotations restart the index at 0, so distinct messages collided
|
||||
# on the same ``message_id``.
|
||||
messages: list[Message] = []
|
||||
for turn in range(4):
|
||||
messages.append(Message(role="user", contents=[f"user {turn}"]))
|
||||
annotate_message_groups(messages)
|
||||
messages.append(Message(role="assistant", contents=[f"assistant {turn}"]))
|
||||
annotate_message_groups(messages)
|
||||
|
||||
message_ids = [message.message_id for message in messages]
|
||||
assert all(message_ids), "every message should receive an id"
|
||||
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
|
||||
|
||||
|
||||
def test_ensure_message_ids_avoids_existing_id_collisions() -> None:
|
||||
# An auto-generated ``msg_{index}`` must not collide with an id already present
|
||||
# on another message (user-supplied or assigned by an earlier annotation pass).
|
||||
messages = [
|
||||
Message(role="user", contents=["zero"]),
|
||||
Message(role="assistant", contents=["one"], message_id="msg_2"),
|
||||
Message(role="user", contents=["two"]),
|
||||
]
|
||||
annotate_message_groups(messages)
|
||||
|
||||
message_ids = [message.message_id for message in messages]
|
||||
assert message_ids[1] == "msg_2"
|
||||
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
|
||||
|
||||
|
||||
def test_incremental_annotation_avoids_prefix_id_collision() -> None:
|
||||
# Regression for the PR review on #5237: when only a suffix is re-annotated,
|
||||
# an auto-assigned ``msg_{index}`` in the suffix must not collide with a
|
||||
# preexisting id carried by a message in the *preserved prefix* (a group
|
||||
# before the one re-annotation pulls back to). Otherwise ``_group_id_for``
|
||||
# derives the same group id and merges groups across the boundary.
|
||||
messages = [
|
||||
# Out-of-position, user-supplied id that matches the ``msg_{index}`` the
|
||||
# suffix pass would assign to the appended message below. This message is
|
||||
# two groups back, so it stays outside the re-annotated slice.
|
||||
Message(role="user", contents=["zero"], message_id="msg_2"),
|
||||
Message(role="user", contents=["one"]),
|
||||
]
|
||||
annotate_message_groups(messages)
|
||||
assert messages[0].message_id == "msg_2"
|
||||
assert messages[1].message_id == "msg_1"
|
||||
|
||||
messages.append(Message(role="user", contents=["two"]))
|
||||
annotate_message_groups(messages, from_index=2)
|
||||
|
||||
message_ids = [message.message_id for message in messages]
|
||||
assert all(message_ids), "every message should receive an id"
|
||||
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
|
||||
assert messages[0].message_id == "msg_2"
|
||||
|
||||
|
||||
async def test_truncation_strategy_keeps_system_anchor() -> None:
|
||||
messages = [
|
||||
Message(role="system", contents=["you are helpful"]),
|
||||
@@ -484,6 +542,44 @@ async def test_tool_result_compaction_collapses_old_groups_into_summary() -> Non
|
||||
assert any(m.role == "tool" for m in projected)
|
||||
|
||||
|
||||
async def test_tool_result_compaction_is_idempotent_after_summary_insertion() -> None:
|
||||
"""Re-running compaction after a mid-list summary insertion must not duplicate it.
|
||||
|
||||
Mirrors a subsequent tool-loop iteration (issue #4991): the inserted summary and the
|
||||
excluded originals now persist on the same list, so a second annotate + compaction pass
|
||||
over the same groups should be a no-op rather than collapsing the group again.
|
||||
"""
|
||||
messages = [
|
||||
Message(role="user", contents=["u"]),
|
||||
_assistant_function_call("call-1"),
|
||||
_tool_result("call-1", "r1"),
|
||||
_assistant_function_call("call-2"),
|
||||
_tool_result("call-2", "r2"),
|
||||
Message(role="assistant", contents=["done"]),
|
||||
]
|
||||
strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1)
|
||||
annotate_message_groups(messages)
|
||||
assert await strategy(messages) is True
|
||||
|
||||
summaries_after_first = [m for m in messages if (m.text or "").startswith("[Tool results:")]
|
||||
assert len(summaries_after_first) == 1
|
||||
summary = summaries_after_first[0]
|
||||
summary_group_ids = _group_unknown_value(summary, SUMMARY_OF_GROUP_IDS_KEY)
|
||||
|
||||
# Second pass over the same (now partially compacted) list.
|
||||
annotate_message_groups(messages)
|
||||
changed = await strategy(messages)
|
||||
|
||||
assert changed is False
|
||||
summaries_after_second = [m for m in messages if (m.text or "").startswith("[Tool results:")]
|
||||
assert len(summaries_after_second) == 1
|
||||
assert _group_unknown_value(summaries_after_second[0], SUMMARY_OF_GROUP_IDS_KEY) == summary_group_ids
|
||||
|
||||
# The kept tool-call group stays atomic and included.
|
||||
projected = included_messages(messages)
|
||||
assert any(m.role == "tool" for m in projected)
|
||||
|
||||
|
||||
async def test_tool_result_compaction_zero_collapses_all() -> None:
|
||||
"""With keep=0, all tool-call groups are collapsed into summaries."""
|
||||
messages = [
|
||||
|
||||
@@ -3975,3 +3975,425 @@ async def test_user_input_request_empty_contents_returns_fallback(chat_client_ba
|
||||
]
|
||||
assert len(function_results) >= 1
|
||||
assert any("user input" in (fr.result or "").lower() for fr in function_results)
|
||||
|
||||
|
||||
# region Progressive tool exposure (FunctionInvocationContext.add_tools / remove_tools)
|
||||
|
||||
|
||||
def _pte_function_call_response(call_id: str, name: str, arguments: str = "{}") -> ChatResponse:
|
||||
return ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_function_call(call_id=call_id, name=name, arguments=arguments)],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _pte_text_response(text: str = "done") -> ChatResponse:
|
||||
return ChatResponse(messages=Message(role="assistant", contents=[text]))
|
||||
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def _pte_factorial(n: int) -> int:
|
||||
"""Compute the factorial of n."""
|
||||
result = 1
|
||||
for value in range(2, n + 1):
|
||||
result *= value
|
||||
return result
|
||||
|
||||
|
||||
async def test_context_exposes_live_tools(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
seen_names: list[str] = []
|
||||
|
||||
@tool(name="inspect_tools", approval_mode="never_require")
|
||||
def inspect_tools(ctx: FunctionInvocationContext) -> str:
|
||||
assert ctx.tools is not None
|
||||
seen_names.extend(t.name for t in ctx.tools if isinstance(t, FunctionTool))
|
||||
return "inspected"
|
||||
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "inspect_tools"),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [inspect_tools]},
|
||||
)
|
||||
assert "inspect_tools" in seen_names
|
||||
|
||||
|
||||
async def test_add_tools_available_next_iteration(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def factorial(n: int) -> int:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(factorial)
|
||||
return "math tools loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
response = await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["compute 5!"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
assert exec_counter == 1
|
||||
assert response.messages[-1].text == "done"
|
||||
|
||||
|
||||
async def test_add_tools_model_sees_added_tools_in_options(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
recorded: list[list[str]] = []
|
||||
client_cls = type(chat_client_base)
|
||||
original = client_cls._get_non_streaming_response
|
||||
|
||||
async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse:
|
||||
tools = options.get("tools") or []
|
||||
recorded.append([t.name for t in tools if isinstance(t, FunctionTool)])
|
||||
return await original(self, messages=messages, options=options, **kwargs)
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(_pte_factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(client_cls, "_get_non_streaming_response", recording)
|
||||
try:
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["compute 5!"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert recorded[0] == ["load_math"]
|
||||
assert "factorial" in recorded[1]
|
||||
|
||||
|
||||
async def test_remove_tools_next_iteration(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
recorded: list[list[str]] = []
|
||||
client_cls = type(chat_client_base)
|
||||
original = client_cls._get_non_streaming_response
|
||||
|
||||
async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse:
|
||||
tools = options.get("tools") or []
|
||||
recorded.append([t.name for t in tools if isinstance(t, FunctionTool)])
|
||||
return await original(self, messages=messages, options=options, **kwargs)
|
||||
|
||||
@tool(name="get_weather", approval_mode="never_require")
|
||||
def get_weather(location: str) -> str:
|
||||
return "sunny"
|
||||
|
||||
@tool(name="drop_weather", approval_mode="never_require")
|
||||
def drop_weather(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.remove_tools("get_weather")
|
||||
return "removed"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "drop_weather"),
|
||||
_pte_text_response(),
|
||||
]
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(client_cls, "_get_non_streaming_response", recording)
|
||||
try:
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [get_weather, drop_weather]},
|
||||
)
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert set(recorded[0]) == {"get_weather", "drop_weather"}
|
||||
assert "get_weather" not in recorded[1]
|
||||
|
||||
|
||||
async def test_add_tools_does_not_mutate_caller_tools_list(chat_client_base: SupportsChatGetResponse):
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(_pte_factorial)
|
||||
return "loaded"
|
||||
|
||||
original_tools: list[Any] = [load_math]
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": original_tools},
|
||||
)
|
||||
assert original_tools == [load_math]
|
||||
|
||||
|
||||
async def test_add_tools_persists_across_iterations(chat_client_base: SupportsChatGetResponse):
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
recorded: list[list[str]] = []
|
||||
client_cls = type(chat_client_base)
|
||||
original = client_cls._get_non_streaming_response
|
||||
|
||||
async def recording(self: Any, *, messages: Any, options: dict[str, Any], **kwargs: Any) -> ChatResponse:
|
||||
tools = options.get("tools") or []
|
||||
recorded.append([t.name for t in tools if isinstance(t, FunctionTool)])
|
||||
return await original(self, messages=messages, options=options, **kwargs)
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(_pte_factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 4 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_function_call_response("3", "factorial", '{"n": 3}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr(client_cls, "_get_non_streaming_response", recording)
|
||||
try:
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
finally:
|
||||
monkey.undo()
|
||||
|
||||
assert "factorial" in recorded[1]
|
||||
assert "factorial" in recorded[2]
|
||||
|
||||
|
||||
async def test_add_tools_through_function_middleware(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
class PassthroughMiddleware(FunctionMiddleware):
|
||||
async def process(self, context: FunctionInvocationContext, call_next: Any) -> None:
|
||||
await call_next()
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def factorial(n: int) -> int:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
middleware=[PassthroughMiddleware()],
|
||||
)
|
||||
assert exec_counter == 1
|
||||
|
||||
|
||||
async def test_add_tools_with_approval_required_tool(chat_client_base: SupportsChatGetResponse):
|
||||
@tool(name="secure_tool", approval_mode="always_require")
|
||||
def secure_tool(value: str) -> str:
|
||||
return f"secure: {value}"
|
||||
|
||||
@tool(name="load_secure", approval_mode="never_require")
|
||||
def load_secure(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(secure_tool)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_secure"),
|
||||
_pte_function_call_response("2", "secure_tool", '{"value": "x"}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
response = await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_secure]},
|
||||
)
|
||||
assert any(item.type == "function_approval_request" for msg in response.messages for item in msg.contents)
|
||||
|
||||
|
||||
async def test_add_tools_accepts_plain_callable(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
def plain_factorial(n: int) -> int:
|
||||
"""Compute factorial."""
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(plain_factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.run_responses = [
|
||||
_pte_function_call_response("1", "load_math"),
|
||||
_pte_function_call_response("2", "plain_factorial", '{"n": 5}'),
|
||||
_pte_text_response(),
|
||||
]
|
||||
await chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
)
|
||||
assert exec_counter == 1
|
||||
|
||||
|
||||
async def test_add_tools_streaming(chat_client_base: SupportsChatGetResponse):
|
||||
exec_counter = 0
|
||||
|
||||
@tool(name="factorial", approval_mode="never_require")
|
||||
def factorial(n: int) -> int:
|
||||
nonlocal exec_counter
|
||||
exec_counter += 1
|
||||
return 120
|
||||
|
||||
@tool(name="load_math", approval_mode="never_require")
|
||||
def load_math(ctx: FunctionInvocationContext) -> str:
|
||||
ctx.add_tools(factorial)
|
||||
return "loaded"
|
||||
|
||||
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
|
||||
chat_client_base.streaming_responses = [
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[Content.from_function_call(call_id="1", name="load_math", arguments="{}")],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[Content.from_function_call(call_id="2", name="factorial", arguments='{"n": 5}')],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
[ChatResponseUpdate(contents=[Content.from_text("done")], role="assistant", finish_reason="stop")],
|
||||
]
|
||||
async for _ in chat_client_base.get_response(
|
||||
[Message(role="user", contents=["hi"])],
|
||||
stream=True,
|
||||
options={"tool_choice": "auto", "tools": [load_math]},
|
||||
):
|
||||
pass
|
||||
assert exec_counter == 1
|
||||
|
||||
|
||||
def test_add_tools_duplicate_same_object_is_noop():
|
||||
@tool(name="dup", approval_mode="never_require")
|
||||
def dup(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=dup, arguments={}, tools=[dup])
|
||||
ctx.add_tools(dup)
|
||||
assert ctx.tools is not None
|
||||
assert len(ctx.tools) == 1
|
||||
|
||||
|
||||
def test_add_tools_duplicate_name_different_object_raises():
|
||||
@tool(name="dup", approval_mode="never_require")
|
||||
def dup_a(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="dup", approval_mode="never_require")
|
||||
def dup_b(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=dup_a, arguments={}, tools=[dup_a])
|
||||
with pytest.raises(ValueError):
|
||||
ctx.add_tools(dup_b)
|
||||
|
||||
|
||||
def test_add_tools_batch_with_duplicate_is_atomic():
|
||||
"""A duplicate-name clash partway through a batch must leave the live list unchanged."""
|
||||
|
||||
@tool(name="existing", approval_mode="never_require")
|
||||
def existing(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="fresh", approval_mode="never_require")
|
||||
def fresh(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="existing", approval_mode="never_require")
|
||||
def clashing(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=existing, arguments={}, tools=[existing])
|
||||
with pytest.raises(ValueError):
|
||||
ctx.add_tools([fresh, clashing])
|
||||
assert ctx.tools is not None
|
||||
# The valid "fresh" tool must not have been committed before the clash raised.
|
||||
assert ctx.tools == [existing]
|
||||
|
||||
|
||||
def test_remove_tools_by_name_and_object():
|
||||
@tool(name="a", approval_mode="never_require")
|
||||
def a(x: int) -> int:
|
||||
return x
|
||||
|
||||
@tool(name="b", approval_mode="never_require")
|
||||
def b(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=a, arguments={}, tools=[a, b])
|
||||
ctx.remove_tools("a")
|
||||
assert ctx.tools is not None
|
||||
assert [t.name for t in ctx.tools] == ["b"]
|
||||
ctx.remove_tools(b)
|
||||
assert ctx.tools == []
|
||||
|
||||
|
||||
def test_remove_tools_unknown_name_is_noop():
|
||||
@tool(name="a", approval_mode="never_require")
|
||||
def a(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=a, arguments={}, tools=[a])
|
||||
ctx.remove_tools("nonexistent")
|
||||
assert ctx.tools is not None
|
||||
assert [t.name for t in ctx.tools] == ["a"]
|
||||
|
||||
|
||||
def test_progressive_tools_helpers_raise_without_live_tools():
|
||||
@tool(name="a", approval_mode="never_require")
|
||||
def a(x: int) -> int:
|
||||
return x
|
||||
|
||||
ctx = FunctionInvocationContext(function=a, arguments={})
|
||||
assert ctx.tools is None
|
||||
with pytest.raises(RuntimeError):
|
||||
ctx.add_tools(a)
|
||||
with pytest.raises(RuntimeError):
|
||||
ctx.remove_tools("a")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for MCP client span instrumentation per OTel GenAI Semantic Conventions.
|
||||
|
||||
See: https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#client
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from mcp import types
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
|
||||
from agent_framework import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
# region helpers
|
||||
|
||||
|
||||
def _make_connected_mcp_tool(
|
||||
name: str = "test-mcp",
|
||||
*,
|
||||
supports_tools: bool = True,
|
||||
supports_prompts: bool = True,
|
||||
) -> MCPTool:
|
||||
"""Create an MCPTool with a mocked session, ready for testing."""
|
||||
tool = MCPTool(name=name)
|
||||
tool.session = AsyncMock()
|
||||
tool.is_connected = True
|
||||
tool._supports_tools = supports_tools
|
||||
tool._supports_prompts = supports_prompts
|
||||
tool.load_tools_flag = True
|
||||
tool.load_prompts_flag = True
|
||||
return tool
|
||||
|
||||
|
||||
def _make_tool_list_result(
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> Mock:
|
||||
"""Create a mock ListToolsResult."""
|
||||
if tools is None:
|
||||
tools = [{"name": "get-weather", "description": "Get weather", "inputSchema": {"type": "object"}}]
|
||||
result = Mock()
|
||||
result.tools = [
|
||||
types.Tool(name=t["name"], description=t.get("description", ""), inputSchema=t.get("inputSchema", {}))
|
||||
for t in tools
|
||||
]
|
||||
result.nextCursor = None
|
||||
return result
|
||||
|
||||
|
||||
def _make_prompt_list_result(
|
||||
prompts: list[dict[str, Any]] | None = None,
|
||||
) -> Mock:
|
||||
"""Create a mock ListPromptsResult."""
|
||||
if prompts is None:
|
||||
prompts = [{"name": "analyze-code", "description": "Analyze code"}]
|
||||
result = Mock()
|
||||
result.prompts = [
|
||||
types.Prompt(name=p["name"], description=p.get("description", ""), arguments=None) for p in prompts
|
||||
]
|
||||
result.nextCursor = None
|
||||
return result
|
||||
|
||||
|
||||
def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock:
|
||||
"""Create a mock CallToolResult."""
|
||||
result = Mock()
|
||||
result.isError = is_error
|
||||
result.content = [types.TextContent(type="text", text=text)]
|
||||
return result
|
||||
|
||||
|
||||
def _make_get_prompt_result(text: str = "prompt result") -> types.GetPromptResult:
|
||||
"""Create a mock GetPromptResult."""
|
||||
return types.GetPromptResult(
|
||||
description="test prompt",
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text=text),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region initialize span
|
||||
|
||||
|
||||
async def test_mcp_initialize_span(span_exporter: InMemorySpanExporter):
|
||||
"""session.initialize() should produce an MCP CLIENT span named 'initialize'."""
|
||||
tool = MCPTool(name="test-server")
|
||||
|
||||
mock_session_cls = AsyncMock()
|
||||
init_result = Mock()
|
||||
init_result.capabilities = None
|
||||
init_result.protocolVersion = "2025-06-18"
|
||||
mock_session_cls.initialize = AsyncMock(return_value=init_result)
|
||||
|
||||
# Create a mock transport context manager
|
||||
mock_transport = AsyncMock()
|
||||
mock_transport.__aenter__ = AsyncMock(return_value=(Mock(), Mock()))
|
||||
mock_transport.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
# Mock get_mcp_client and the session creation
|
||||
tool.session = None
|
||||
tool.load_tools_flag = False
|
||||
tool.load_prompts_flag = False
|
||||
|
||||
span_exporter.clear()
|
||||
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr(tool, "get_mcp_client", lambda: mock_transport)
|
||||
|
||||
async def patched_connect(self_: Any, *, reset: bool = False, load_configured: bool = True) -> None:
|
||||
# Simulate _connect_on_owner: create initialize span and call session.initialize()
|
||||
from agent_framework._mcp import create_mcp_client_span
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
with create_mcp_client_span("initialize", attributes=self_._mcp_base_span_attributes()) as init_span:
|
||||
result = await mock_session_cls.initialize()
|
||||
protocol_version = getattr(result, "protocolVersion", None)
|
||||
if protocol_version:
|
||||
init_span.set_attribute(OtelAttr.MCP_PROTOCOL_VERSION, protocol_version)
|
||||
|
||||
self_.session = mock_session_cls
|
||||
self_.is_connected = True
|
||||
|
||||
m.setattr(MCPTool, "_connect_on_owner", patched_connect)
|
||||
await tool.connect()
|
||||
|
||||
mock_session_cls.initialize.assert_awaited_once()
|
||||
spans = span_exporter.get_finished_spans()
|
||||
init_spans = [s for s in spans if s.name == "initialize"]
|
||||
assert len(init_spans) == 1
|
||||
span = init_spans[0]
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.attributes[OtelAttr.MCP_METHOD_NAME] == "initialize"
|
||||
assert span.attributes.get(OtelAttr.MCP_PROTOCOL_VERSION) == "2025-06-18"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region tools/list span
|
||||
|
||||
|
||||
async def test_mcp_tools_list_span(span_exporter: InMemorySpanExporter):
|
||||
"""session.list_tools() should produce an MCP CLIENT span named 'tools/list'."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.list_tools = AsyncMock(return_value=_make_tool_list_result())
|
||||
|
||||
span_exporter.clear()
|
||||
await tool.load_tools()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
list_spans = [s for s in spans if s.name == "tools/list"]
|
||||
assert len(list_spans) == 1
|
||||
span = list_spans[0]
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.attributes[OtelAttr.MCP_METHOD_NAME] == "tools/list"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region prompts/list span
|
||||
|
||||
|
||||
async def test_mcp_prompts_list_span(span_exporter: InMemorySpanExporter):
|
||||
"""session.list_prompts() should produce an MCP CLIENT span named 'prompts/list'."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.list_prompts = AsyncMock(return_value=_make_prompt_list_result())
|
||||
|
||||
span_exporter.clear()
|
||||
await tool.load_prompts()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
list_spans = [s for s in spans if s.name == "prompts/list"]
|
||||
assert len(list_spans) == 1
|
||||
span = list_spans[0]
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.attributes[OtelAttr.MCP_METHOD_NAME] == "prompts/list"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region tools/call span
|
||||
|
||||
|
||||
async def test_mcp_tools_call_creates_client_span_when_no_parent(span_exporter: InMemorySpanExporter):
|
||||
"""Direct call_tool() without FunctionTool wrapper creates new MCP CLIENT span."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.call_tool = AsyncMock(return_value=_make_call_tool_result("hello"))
|
||||
|
||||
span_exporter.clear()
|
||||
result = await tool.call_tool("get-weather", city="Seattle")
|
||||
|
||||
assert result is not None
|
||||
spans = span_exporter.get_finished_spans()
|
||||
call_spans = [s for s in spans if "tools/call" in s.name]
|
||||
assert len(call_spans) == 1
|
||||
span = call_spans[0]
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.name == "tools/call get-weather"
|
||||
assert span.attributes[OtelAttr.MCP_METHOD_NAME] == "tools/call"
|
||||
assert span.attributes[OtelAttr.TOOL_NAME] == "get-weather"
|
||||
|
||||
|
||||
async def test_mcp_tools_call_tool_error_sets_error_type(span_exporter: InMemorySpanExporter):
|
||||
"""When CallToolResult.isError is true, error.type should be 'tool_error' per MCP spec."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.call_tool = AsyncMock(return_value=_make_call_tool_result("bad input", is_error=True))
|
||||
|
||||
span_exporter.clear()
|
||||
with pytest.raises(ToolExecutionException):
|
||||
await tool.call_tool("get-weather", city="invalid")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
call_spans = [s for s in spans if "tools/call" in s.name]
|
||||
assert len(call_spans) == 1
|
||||
span = call_spans[0]
|
||||
assert span.attributes.get(OtelAttr.ERROR_TYPE) == "tool_error"
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
async def test_mcp_tools_call_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
|
||||
"""When session.call_tool() raises McpError, error.type should be the exception class name."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.call_tool = AsyncMock(side_effect=McpError(ErrorData(code=-32600, message="invalid request")))
|
||||
|
||||
span_exporter.clear()
|
||||
with pytest.raises(ToolExecutionException):
|
||||
await tool.call_tool("get-weather")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
call_spans = [s for s in spans if "tools/call" in s.name]
|
||||
assert len(call_spans) == 1
|
||||
span = call_spans[0]
|
||||
assert span.attributes.get(OtelAttr.ERROR_TYPE) == "McpError"
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region prompts/get span
|
||||
|
||||
|
||||
async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanExporter):
|
||||
"""get_prompt() should always create a new MCP CLIENT span (not enrich execute_tool)."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.get_prompt = AsyncMock(return_value=_make_get_prompt_result("code analysis"))
|
||||
|
||||
span_exporter.clear()
|
||||
result = await tool.get_prompt("analyze-code", language="python")
|
||||
|
||||
assert "code analysis" in result
|
||||
spans = span_exporter.get_finished_spans()
|
||||
prompt_spans = [s for s in spans if "prompts/get" in s.name]
|
||||
assert len(prompt_spans) == 1
|
||||
span = prompt_spans[0]
|
||||
assert span.kind == SpanKind.CLIENT
|
||||
assert span.name == "prompts/get analyze-code"
|
||||
assert span.attributes[OtelAttr.MCP_METHOD_NAME] == "prompts/get"
|
||||
assert span.attributes[OtelAttr.PROMPT_NAME] == "analyze-code"
|
||||
|
||||
|
||||
async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
|
||||
"""When session.get_prompt() raises McpError, the span should have error.type and ERROR status."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.get_prompt = AsyncMock(
|
||||
side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))
|
||||
)
|
||||
|
||||
span_exporter.clear()
|
||||
with pytest.raises(ToolExecutionException):
|
||||
await tool.get_prompt("missing-prompt")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
prompt_spans = [s for s in spans if "prompts/get" in s.name]
|
||||
assert len(prompt_spans) == 1
|
||||
span = prompt_spans[0]
|
||||
assert span.attributes.get(OtelAttr.ERROR_TYPE) == "McpError"
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region transport attributes
|
||||
|
||||
|
||||
def test_mcp_stdio_tool_transport_attributes():
|
||||
"""MCPStdioTool should have network.transport='pipe'."""
|
||||
tool = MCPStdioTool(name="test", command="python")
|
||||
attrs = tool._mcp_base_span_attributes()
|
||||
assert attrs[OtelAttr.NETWORK_TRANSPORT] == "pipe"
|
||||
assert OtelAttr.ADDRESS not in attrs
|
||||
|
||||
|
||||
def test_mcp_http_tool_transport_attributes():
|
||||
"""MCPStreamableHTTPTool should have tcp transport and URL-based server address/port."""
|
||||
tool = MCPStreamableHTTPTool(name="test", url="https://api.example.com:8443/mcp")
|
||||
attrs = tool._mcp_base_span_attributes()
|
||||
assert attrs[OtelAttr.NETWORK_TRANSPORT] == "tcp"
|
||||
assert attrs[OtelAttr.NETWORK_PROTOCOL_NAME] == "http"
|
||||
assert attrs[OtelAttr.ADDRESS] == "api.example.com"
|
||||
assert attrs[OtelAttr.PORT] == 8443
|
||||
|
||||
|
||||
def test_mcp_http_tool_default_port():
|
||||
"""MCPStreamableHTTPTool should default to 443 for https."""
|
||||
tool = MCPStreamableHTTPTool(name="test", url="https://api.example.com/mcp")
|
||||
attrs = tool._mcp_base_span_attributes()
|
||||
assert attrs[OtelAttr.PORT] == 443
|
||||
|
||||
|
||||
def test_mcp_http_tool_http_default_port():
|
||||
"""MCPStreamableHTTPTool should default to 80 for http."""
|
||||
tool = MCPStreamableHTTPTool(name="test", url="http://localhost/mcp")
|
||||
attrs = tool._mcp_base_span_attributes()
|
||||
assert attrs[OtelAttr.PORT] == 80
|
||||
|
||||
|
||||
def test_mcp_websocket_tool_transport_attributes():
|
||||
"""MCPWebsocketTool should have tcp transport and URL-based server address/port."""
|
||||
tool = MCPWebsocketTool(name="test", url="wss://ws.example.com:9090/mcp")
|
||||
attrs = tool._mcp_base_span_attributes()
|
||||
assert attrs[OtelAttr.NETWORK_TRANSPORT] == "tcp"
|
||||
assert attrs[OtelAttr.NETWORK_PROTOCOL_NAME] == "websocket"
|
||||
assert attrs[OtelAttr.ADDRESS] == "ws.example.com"
|
||||
assert attrs[OtelAttr.PORT] == 9090
|
||||
|
||||
|
||||
def test_mcp_websocket_tool_default_port():
|
||||
"""MCPWebsocketTool should default to 443 for wss."""
|
||||
tool = MCPWebsocketTool(name="test", url="wss://ws.example.com/mcp")
|
||||
attrs = tool._mcp_base_span_attributes()
|
||||
assert attrs[OtelAttr.PORT] == 443
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region observability disabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_instrumentation", [False], indirect=True)
|
||||
async def test_mcp_spans_not_created_when_observability_disabled(span_exporter: InMemorySpanExporter):
|
||||
"""No MCP spans should be created when observability is disabled."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.list_tools = AsyncMock(return_value=_make_tool_list_result())
|
||||
tool.session.call_tool = AsyncMock(return_value=_make_call_tool_result("ok"))
|
||||
|
||||
span_exporter.clear()
|
||||
await tool.load_tools()
|
||||
await tool.call_tool("get-weather", city="Seattle")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 0
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,667 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for MCP-based skills (MCPSkillsSource, MCPSkill, MCPSkillResource)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
ErrorData,
|
||||
ReadResourceResult,
|
||||
TextResourceContents,
|
||||
)
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from agent_framework import MCPSkill, MCPSkillResource, MCPSkillsSource
|
||||
from agent_framework._skills import _parse_mcp_skill_index
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures & helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_SKILL_MD = """\
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units.
|
||||
---
|
||||
# Unit Converter
|
||||
|
||||
Body content here.
|
||||
"""
|
||||
|
||||
SAMPLE_SKILL_INDEX = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with a single TextResourceContents."""
|
||||
return ReadResourceResult(
|
||||
contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")]
|
||||
)
|
||||
|
||||
|
||||
def _make_blob_result(
|
||||
data: bytes,
|
||||
uri: str = "skill://test",
|
||||
mime_type: str = "application/octet-stream",
|
||||
) -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with a single BlobResourceContents."""
|
||||
return ReadResourceResult(
|
||||
contents=[BlobResourceContents(uri=AnyUrl(uri), blob=base64.b64encode(data).decode(), mimeType=mime_type)]
|
||||
)
|
||||
|
||||
|
||||
def _make_empty_result() -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with no contents."""
|
||||
return ReadResourceResult(contents=[])
|
||||
|
||||
|
||||
def _make_client(**read_resource_responses: ReadResourceResult) -> AsyncMock:
|
||||
"""Create a mock ClientSession whose read_resource returns different results per URI.
|
||||
|
||||
Args:
|
||||
**read_resource_responses: Mapping of URI string to ReadResourceResult.
|
||||
Any URI not in this mapping raises McpError with the MCP-spec
|
||||
"Resource not found" code (-32002).
|
||||
"""
|
||||
client = AsyncMock()
|
||||
|
||||
async def _read_resource(uri: AnyUrl) -> ReadResourceResult:
|
||||
uri_str = str(uri)
|
||||
if uri_str in read_resource_responses:
|
||||
return read_resource_responses[uri_str]
|
||||
raise McpError(error=ErrorData(code=-32002, message=f"Resource not found: {uri_str}"))
|
||||
|
||||
client.read_resource = AsyncMock(side_effect=_read_resource)
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_mcp_skill_index tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseMCPSkillIndex:
|
||||
"""Tests for the _parse_mcp_skill_index helper."""
|
||||
|
||||
def test_parses_valid_index(self) -> None:
|
||||
index = _parse_mcp_skill_index(SAMPLE_SKILL_INDEX)
|
||||
assert index.schema == "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
|
||||
assert len(index.skills) == 1
|
||||
assert index.skills[0].name == "unit-converter"
|
||||
assert index.skills[0].type == "skill-md"
|
||||
assert index.skills[0].url == "skill://unit-converter/SKILL.md"
|
||||
|
||||
def test_parses_empty_skills_array(self) -> None:
|
||||
index = _parse_mcp_skill_index('{"$schema": "test", "skills": []}')
|
||||
assert index.skills == []
|
||||
|
||||
def test_parses_missing_skills_key(self) -> None:
|
||||
index = _parse_mcp_skill_index('{"$schema": "test"}')
|
||||
assert index.skills == []
|
||||
|
||||
def test_raises_on_non_object(self) -> None:
|
||||
with pytest.raises(ValueError, match="must be a JSON object"):
|
||||
_parse_mcp_skill_index("[]")
|
||||
|
||||
def test_raises_on_invalid_json(self) -> None:
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_parse_mcp_skill_index("not json")
|
||||
|
||||
def test_skips_non_dict_entries(self) -> None:
|
||||
index = _parse_mcp_skill_index('{"skills": ["not-a-dict", {"name": "ok", "type": "skill-md"}]}')
|
||||
assert len(index.skills) == 1
|
||||
assert index.skills[0].name == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSkillResource tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkillResource:
|
||||
"""Tests for MCPSkillResource."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_text_content(self) -> None:
|
||||
result = _make_text_result("hello world")
|
||||
resource = MCPSkillResource(name="test.md", result=result)
|
||||
content = await resource.read()
|
||||
assert content == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_binary_content(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
result = _make_blob_result(data)
|
||||
resource = MCPSkillResource(name="icon.bin", result=result)
|
||||
content = await resource.read()
|
||||
assert content == data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_empty_returns_none(self) -> None:
|
||||
result = _make_empty_result()
|
||||
resource = MCPSkillResource(name="empty", result=result)
|
||||
content = await resource.read()
|
||||
assert content is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_multiple_text_contents_joined(self) -> None:
|
||||
result = ReadResourceResult(
|
||||
contents=[
|
||||
TextResourceContents(uri=AnyUrl("skill://a"), text="line1", mimeType="text/plain"),
|
||||
TextResourceContents(uri=AnyUrl("skill://b"), text="line2", mimeType="text/plain"),
|
||||
]
|
||||
)
|
||||
resource = MCPSkillResource(name="multi", result=result)
|
||||
content = await resource.read()
|
||||
assert content == "line1\nline2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_takes_precedence_over_text(self) -> None:
|
||||
data = b"\xff\xfe"
|
||||
result = ReadResourceResult(
|
||||
contents=[
|
||||
TextResourceContents(uri=AnyUrl("skill://a"), text="text", mimeType="text/plain"),
|
||||
BlobResourceContents(
|
||||
uri=AnyUrl("skill://b"),
|
||||
blob=base64.b64encode(data).decode(),
|
||||
mimeType="application/octet-stream",
|
||||
),
|
||||
]
|
||||
)
|
||||
resource = MCPSkillResource(name="mixed", result=result)
|
||||
content = await resource.read()
|
||||
# The implementation iterates all contents checking for BlobResourceContents
|
||||
# first, so when both text and binary are present, binary is returned.
|
||||
assert content == data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSkill tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkill:
|
||||
"""Tests for MCPSkill."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_fetches_and_caches(self) -> None:
|
||||
client = _make_client(**{"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD)})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
content1 = await skill.get_content()
|
||||
content2 = await skill.get_content()
|
||||
|
||||
assert "Body content here." in content1
|
||||
assert content1 == content2
|
||||
# Only one MCP call should be made (cached)
|
||||
assert client.read_resource.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_raises_on_empty(self) -> None:
|
||||
client = _make_client(**{"skill://empty/SKILL.md": _make_empty_result()})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="empty-skill", description="Empty skill.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://empty/SKILL.md", client=client)
|
||||
|
||||
with pytest.raises(ValueError, match="no text content"):
|
||||
await skill.get_content()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_text(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource("references/checklist.md")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == "- check thing 1\n- check thing 2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_binary(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource("assets/icon.bin")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_unknown_returns_none(self) -> None:
|
||||
client = _make_client(**{"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD)})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource("references/does-not-exist.md")
|
||||
assert resource is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"../escape.md",
|
||||
"references/../../escape.md",
|
||||
"..",
|
||||
"..\\escape.md",
|
||||
"/etc/passwd",
|
||||
"http://attacker.example.com/payload",
|
||||
],
|
||||
)
|
||||
async def test_get_resource_path_traversal_returns_none(self, name: str) -> None:
|
||||
# Register a permissive mock that would happily return content for any URI,
|
||||
# so the test fails unless the client-side validation rejects the name
|
||||
# before issuing the read.
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(return_value=_make_text_result("should never be returned"))
|
||||
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://unit-converter/SKILL.md", client=client)
|
||||
|
||||
resource = await skill.get_resource(name)
|
||||
assert resource is None
|
||||
client.read_resource.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_empty_name_returns_none(self) -> None:
|
||||
client = _make_client()
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
|
||||
assert await skill.get_resource("") is None
|
||||
assert await skill.get_resource(" ") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_script_returns_none(self) -> None:
|
||||
client = _make_client()
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
|
||||
assert await skill.get_script("anything") is None
|
||||
|
||||
def test_compute_skill_root_uri_strips_suffix(self) -> None:
|
||||
assert MCPSkill._compute_skill_root_uri("skill://unit-converter/SKILL.md") == "skill://unit-converter/"
|
||||
|
||||
def test_compute_skill_root_uri_trailing_slash(self) -> None:
|
||||
assert MCPSkill._compute_skill_root_uri("skill://unit-converter/") == "skill://unit-converter/"
|
||||
|
||||
def test_compute_skill_root_uri_no_suffix_adds_slash(self) -> None:
|
||||
assert MCPSkill._compute_skill_root_uri("skill://unit-converter") == "skill://unit-converter/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSkillsSource tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkillsSource:
|
||||
"""Tests for MCPSkillsSource."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_based_discovery_returns_skill(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].frontmatter.name == "unit-converter"
|
||||
assert skills[0].frontmatter.description == "Convert between common units."
|
||||
|
||||
# Content is fetched on demand, not during discovery
|
||||
content = await skills[0].get_content()
|
||||
assert "Body content here." in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_index_returns_empty(self) -> None:
|
||||
client = _make_client() # No resources at all
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_read_skill_md_during_discovery(self) -> None:
|
||||
# Index points to a skill, but SKILL.md is not registered on the server.
|
||||
# Discovery should succeed because it only reads the index.
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].frontmatter.name == "unit-converter"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_name_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter", # Invalid: uppercase
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_fields_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
# Missing description and url
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_index_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_index_json_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_text_resource(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("references/checklist.md")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == "- check thing 1\n- check thing 2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_binary_resource(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("assets/icon.bin")
|
||||
assert resource is not None
|
||||
content = await resource.read()
|
||||
assert content == data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# McpError code branching tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMCPSkillsSourceErrorCodeBranching:
|
||||
"""Tests that MCPSkillsSource and MCPSkill branch on McpError.error.code.
|
||||
|
||||
Only "not found" codes (RESOURCE_NOT_FOUND -32002, METHOD_NOT_FOUND -32601)
|
||||
should be silently swallowed as "no skills available." Other McpError codes
|
||||
and non-McpError exceptions must propagate so that auth failures, server
|
||||
crashes, and connection drops are visible.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_method_not_found_returns_empty(self) -> None:
|
||||
"""METHOD_NOT_FOUND (-32601) -> server doesn't support resources/read."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32601, message="Method not found")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_resource_not_found_returns_empty(self) -> None:
|
||||
"""MCP-spec "Resource not found" (-32002) -> server has no index."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=-32002, message="Resource not found"))
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_invalid_params_propagates(self) -> None:
|
||||
"""INVALID_PARAMS (-32602) is a real bug, must propagate (not "not found")."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32602, message="Invalid params")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_internal_error_propagates(self) -> None:
|
||||
"""INTERNAL_ERROR (-32603) must propagate, not silently return empty."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32603, message="Internal error")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_connection_closed_propagates(self) -> None:
|
||||
"""CONNECTION_CLOSED (-32000) must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=-32000, message="Connection closed"))
|
||||
)
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_generic_error_code_propagates(self) -> None:
|
||||
"""Generic handler error (code 0) must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=0, message="Some handler error")))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(McpError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_non_mcp_error_propagates(self) -> None:
|
||||
"""Non-McpError exceptions (connection drop, timeout) must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=ConnectionError("connection lost"))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(ConnectionError):
|
||||
await source.get_skills()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_internal_error_propagates(self) -> None:
|
||||
"""McpError with INTERNAL_ERROR on get_resource must propagate."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=-32603, message="Server crashed")))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(McpError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_not_found_returns_none(self) -> None:
|
||||
"""McpError with RESOURCE_NOT_FOUND (-32002) on get_resource returns None."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=-32002, message="Resource not found"))
|
||||
)
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
result = await skill.get_resource("references/file.md")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_connection_error_propagates(self) -> None:
|
||||
"""A plain ConnectionError on get_resource must propagate, not return None."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=ConnectionError("connection lost"))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(ConnectionError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_timeout_error_propagates(self) -> None:
|
||||
"""A TimeoutError on get_resource must propagate, not return None."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=TimeoutError("read timed out"))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(TimeoutError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_generic_mcp_error_propagates(self) -> None:
|
||||
"""McpError with a generic code (0) on get_resource must propagate."""
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=0, message="Handler error"))
|
||||
)
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(McpError):
|
||||
await skill.get_resource("references/file.md")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_timeout_error_propagates(self) -> None:
|
||||
"""A TimeoutError reading skill://index.json must propagate."""
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(side_effect=TimeoutError("read timed out"))
|
||||
source = MCPSkillsSource(client=client)
|
||||
with pytest.raises(TimeoutError):
|
||||
await source.get_skills()
|
||||
@@ -25,6 +25,7 @@ from agent_framework import (
|
||||
prepend_agent_framework_to_user_agent,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._serialization import make_json_safe
|
||||
from agent_framework.observability import (
|
||||
ROLE_EVENT_MAP,
|
||||
AgentTelemetryLayer,
|
||||
@@ -3195,17 +3196,15 @@ def test_capture_messages_with_prepared_request_info_function_call_arguments(spa
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from agent_framework import WorkflowAgent
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HandoffRequest:
|
||||
target_agent: str
|
||||
reason: str
|
||||
|
||||
arguments = WorkflowAgent.RequestInfoFunctionArgs(
|
||||
request_id="call_dc",
|
||||
data=HandoffRequest(target_agent="helper", reason="overflow"),
|
||||
).to_dict()
|
||||
arguments = {
|
||||
"request_id": "call_dc",
|
||||
"data": make_json_safe(HandoffRequest(target_agent="helper", reason="overflow")),
|
||||
}
|
||||
msg = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Annotated, Any, Literal, get_args, get_origin
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -1346,6 +1348,45 @@ async def test_invoke_skip_parsing_awaits_async_functions() -> None:
|
||||
assert raw == 42
|
||||
|
||||
|
||||
async def test_invoke_sync_tool_does_not_block_event_loop() -> None:
|
||||
release_tool = threading.Event()
|
||||
tool_thread_ids: list[int] = []
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
|
||||
@tool
|
||||
def wait_for_release() -> str:
|
||||
tool_thread_ids.append(threading.get_ident())
|
||||
return "released" if release_tool.wait(timeout=0.2) else "timed out"
|
||||
|
||||
async def release_soon() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
release_tool.set()
|
||||
|
||||
tool_task = asyncio.create_task(wait_for_release.invoke(skip_parsing=True))
|
||||
release_task = asyncio.create_task(release_soon())
|
||||
|
||||
assert await asyncio.wait_for(tool_task, timeout=1) == "released"
|
||||
await release_task
|
||||
assert tool_thread_ids
|
||||
assert tool_thread_ids[0] != event_loop_thread_id
|
||||
|
||||
|
||||
async def test_invoke_sync_tool_can_stay_on_event_loop() -> None:
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
tool_thread_ids: list[int] = []
|
||||
|
||||
@tool
|
||||
def needs_event_loop() -> str:
|
||||
tool_thread_ids.append(threading.get_ident())
|
||||
asyncio.get_running_loop()
|
||||
return "ok"
|
||||
|
||||
needs_event_loop._invoke_sync_on_event_loop = True
|
||||
|
||||
assert await needs_event_loop.invoke(skip_parsing=True) == "ok"
|
||||
assert tool_thread_ids == [event_loop_thread_id]
|
||||
|
||||
|
||||
async def test_invoke_skip_parsing_bypasses_configured_result_parser() -> None:
|
||||
"""The tool's own result_parser is bypassed when skip_parsing=True is requested."""
|
||||
parser_calls: list[Any] = []
|
||||
|
||||
@@ -699,3 +699,171 @@ async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_g
|
||||
resolved = {"exec_a": {}, GLOBAL_KWARGS_KEY: {"global_key": "global_val"}}
|
||||
result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage]
|
||||
assert result == {}
|
||||
|
||||
|
||||
# region Tool approval emission
|
||||
|
||||
|
||||
class _ApprovalEmittingAgent(BaseAgent):
|
||||
"""Agent that returns a single ``function_approval_request`` Content.
|
||||
|
||||
Used to verify that ``AgentExecutor`` does *not* surface the approval
|
||||
payload via both an ``output`` event and a ``request_info`` event in the
|
||||
same superstep — only the ``request_info`` event must carry it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
approval_request_id: str = "apr_1",
|
||||
tool_name: str = "delete_file",
|
||||
tool_arguments: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._approval_request_id = approval_request_id
|
||||
self._tool_name = tool_name
|
||||
self._tool_arguments: dict[str, Any] = tool_arguments or {"path": "/tmp/secret.txt"}
|
||||
self.run_count = 0
|
||||
|
||||
def _build_approval_content(self) -> Content:
|
||||
function_call = Content.from_function_call(
|
||||
call_id=self._approval_request_id,
|
||||
name=self._tool_name,
|
||||
arguments=self._tool_arguments,
|
||||
)
|
||||
return Content.from_function_approval_request(id=self._approval_request_id, function_call=function_call)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = ...,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = ...,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
|
||||
self.run_count += 1
|
||||
approval = self._build_approval_content()
|
||||
|
||||
if stream:
|
||||
|
||||
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[approval], role="assistant")
|
||||
|
||||
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
async def _run() -> AgentResponse:
|
||||
return AgentResponse(messages=[Message("assistant", [approval])])
|
||||
|
||||
return _run()
|
||||
|
||||
|
||||
def _has_approval_payload(event: WorkflowEvent[Any]) -> bool:
|
||||
"""Return True if the event's data carries a ``function_approval_request`` content."""
|
||||
data: Any = event.data
|
||||
|
||||
def _contents_of(value: Any) -> list[Content]:
|
||||
if isinstance(value, AgentResponseUpdate):
|
||||
return list(value.contents)
|
||||
if isinstance(value, AgentResponse):
|
||||
return [c for m in value.messages for c in m.contents]
|
||||
if isinstance(value, AgentExecutorResponse):
|
||||
return [c for m in value.agent_response.messages for c in m.contents]
|
||||
if isinstance(value, Message):
|
||||
return list(value.contents)
|
||||
if isinstance(value, Content):
|
||||
return [value]
|
||||
return []
|
||||
|
||||
return any(c.type == "function_approval_request" for c in _contents_of(data))
|
||||
|
||||
|
||||
async def test_agent_executor_does_not_double_emit_approval_non_streaming() -> None:
|
||||
"""Non-streaming: approval payload must only appear in the ``request_info`` event.
|
||||
|
||||
Regression test for the bug where ``AgentExecutor._run_agent`` first
|
||||
``yield_output``-ed the response (carrying the approval Content) and then
|
||||
additionally emitted a ``request_info`` event for the same payload.
|
||||
"""
|
||||
agent = _ApprovalEmittingAgent(id="approve_agent", name="ApproveAgent", approval_request_id="apr_ns_1")
|
||||
executor = AgentExecutor(agent, id="approve_exec")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
request_info_events: list[WorkflowEvent[Any]] = []
|
||||
output_events: list[WorkflowEvent[Any]] = []
|
||||
|
||||
for event in await workflow.run("please delete it"):
|
||||
if event.type == "request_info":
|
||||
request_info_events.append(event)
|
||||
elif event.type == "output":
|
||||
output_events.append(event)
|
||||
|
||||
assert len(request_info_events) == 1
|
||||
assert _has_approval_payload(request_info_events[0])
|
||||
# The approval payload must not also be surfaced as a workflow output.
|
||||
assert not any(_has_approval_payload(e) for e in output_events)
|
||||
assert agent.run_count == 1
|
||||
|
||||
|
||||
async def test_agent_executor_does_not_double_emit_approval_streaming() -> None:
|
||||
"""Streaming: per-update approval payload must not be ``yield_output``-ed."""
|
||||
agent = _ApprovalEmittingAgent(id="approve_agent_s", name="ApproveAgentS", approval_request_id="apr_st_1")
|
||||
executor = AgentExecutor(agent, id="approve_exec_s")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
request_info_events: list[WorkflowEvent[Any]] = []
|
||||
output_events: list[WorkflowEvent[Any]] = []
|
||||
|
||||
async for event in workflow.run("please delete it", stream=True):
|
||||
if event.type == "request_info":
|
||||
request_info_events.append(event)
|
||||
elif event.type == "output":
|
||||
output_events.append(event)
|
||||
|
||||
assert len(request_info_events) == 1
|
||||
assert _has_approval_payload(request_info_events[0])
|
||||
assert not any(_has_approval_payload(e) for e in output_events)
|
||||
assert agent.run_count == 1
|
||||
|
||||
|
||||
async def test_agent_executor_request_info_uses_user_input_request_id() -> None:
|
||||
"""``ctx.request_info`` must register the request under the agent's approval id.
|
||||
|
||||
This makes the workflow's pending-request id round-trip with the
|
||||
``function_approval_response.id`` the caller echoes back, so
|
||||
``Workflow._send_responses_internal`` can look it up directly.
|
||||
"""
|
||||
agent = _ApprovalEmittingAgent(id="approve_agent_id", name="ApproveAgentId", approval_request_id="apr_match")
|
||||
executor = AgentExecutor(agent, id="approve_exec_id")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
request_info_events: list[WorkflowEvent[Any]] = []
|
||||
async for event in workflow.run("please delete it", stream=True):
|
||||
if event.type == "request_info":
|
||||
request_info_events.append(event)
|
||||
|
||||
assert len(request_info_events) == 1
|
||||
assert request_info_events[0].request_id == "apr_match"
|
||||
|
||||
|
||||
# endregion Tool approval emission
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Sequence
|
||||
from dataclasses import dataclass
|
||||
@@ -30,6 +29,20 @@ from agent_framework import (
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework._workflows._typing_utils import deserialize_type
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffRequest:
|
||||
"""Module-level dataclass used by request_info tests.
|
||||
|
||||
Defined at module scope (not nested inside a test method) so
|
||||
``serialize_type``/``deserialize_type`` can round-trip the request_type via
|
||||
the importable qualified name ``tests.workflow.test_workflow_agent.HandoffRequest``.
|
||||
"""
|
||||
|
||||
target_agent: str
|
||||
reason: str
|
||||
|
||||
|
||||
class SimpleExecutor(Executor):
|
||||
@@ -240,52 +253,45 @@ class TestWorkflowAgent:
|
||||
# Should have received an approval request for the request info
|
||||
assert len(updates) > 0
|
||||
|
||||
approval_update: AgentResponseUpdate | None = None
|
||||
request_update: AgentResponseUpdate | None = None
|
||||
for update in updates:
|
||||
if any(content.type == "function_approval_request" for content in update.contents):
|
||||
approval_update = update
|
||||
if any(content.type == "function_call" for content in update.contents):
|
||||
request_update = update
|
||||
break
|
||||
|
||||
assert approval_update is not None, "Should have received a request_info approval request"
|
||||
assert request_update is not None, "Should have received a request_info wrapped in a function_call content"
|
||||
|
||||
function_call = next(content for content in approval_update.contents if content.type == "function_call")
|
||||
approval_request = next(
|
||||
content for content in approval_update.contents if content.type == "function_approval_request"
|
||||
)
|
||||
request_function_call = next(content for content in request_update.contents if content.type == "function_call")
|
||||
assert request_function_call.call_id is not None
|
||||
|
||||
# Verify the function call has expected structure
|
||||
assert function_call.call_id is not None
|
||||
assert function_call.name == "request_info"
|
||||
assert isinstance(function_call.arguments, dict)
|
||||
assert function_call.arguments.get("request_id") == approval_request.id
|
||||
assert request_function_call.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME
|
||||
assert isinstance(request_function_call.arguments, dict)
|
||||
assert request_function_call.arguments.get("request_id") is not None
|
||||
assert request_function_call.arguments.get("request_event") is not None
|
||||
request_event = request_function_call.arguments["request_event"]
|
||||
assert request_event.get("type") == "request_info"
|
||||
assert deserialize_type(request_event.get("response_type")) is str
|
||||
|
||||
# Approval request should reference the same function call
|
||||
assert approval_request.id is not None
|
||||
assert approval_request.function_call is not None
|
||||
assert approval_request.function_call.call_id == function_call.call_id
|
||||
assert approval_request.function_call.name == function_call.name
|
||||
deserialized_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(request_function_call.arguments)
|
||||
assert deserialized_args.request_id == request_function_call.call_id
|
||||
assert isinstance(deserialized_args.request_event, WorkflowEvent)
|
||||
assert deserialized_args.request_event.type == "request_info"
|
||||
assert deserialized_args.request_event.data == "Mock request data"
|
||||
assert deserialized_args.request_event.response_type is str
|
||||
|
||||
# Verify the request is tracked in pending_requests
|
||||
assert len(agent.pending_requests) == 1
|
||||
assert function_call.call_id in agent.pending_requests
|
||||
pending_requests = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert len(pending_requests) == 1
|
||||
assert request_function_call.call_id in pending_requests
|
||||
|
||||
# Now provide an approval response with updated arguments to test continuation
|
||||
response_args = WorkflowAgent.RequestInfoFunctionArgs(
|
||||
request_id=approval_request.id,
|
||||
data="User provided answer",
|
||||
).to_dict()
|
||||
|
||||
approval_response = Content.from_function_approval_response(
|
||||
approved=True,
|
||||
id=approval_request.id,
|
||||
function_call=Content.from_function_call(
|
||||
call_id=function_call.call_id,
|
||||
name=function_call.name,
|
||||
arguments=response_args,
|
||||
),
|
||||
# Now provide a function result response with updated arguments to test continuation
|
||||
function_result = Content.from_function_result(
|
||||
call_id=request_function_call.call_id,
|
||||
result="Mock response to request info",
|
||||
)
|
||||
|
||||
response_message = Message(role="user", contents=[approval_response])
|
||||
response_message = Message(role="user", contents=[function_result])
|
||||
|
||||
# Continue the workflow with the response
|
||||
continuation_result = await agent.run(response_message)
|
||||
@@ -294,16 +300,11 @@ class TestWorkflowAgent:
|
||||
assert isinstance(continuation_result, AgentResponse)
|
||||
|
||||
# Verify cleanup - pending requests should be cleared after function response handling
|
||||
assert len(agent.pending_requests) == 0
|
||||
pending_requests = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert len(pending_requests) == 0
|
||||
|
||||
def test_request_info_dataclass_arguments_are_serialized_when_content_is_created(self) -> None:
|
||||
"""Test WorkflowAgent prepares request_info arguments before observability captures messages."""
|
||||
|
||||
@dataclass
|
||||
class HandoffRequest:
|
||||
target_agent: str
|
||||
reason: str
|
||||
|
||||
executor = SimpleExecutor(id="executor1", response_text="Response")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Request Test Agent")
|
||||
@@ -314,14 +315,367 @@ class TestWorkflowAgent:
|
||||
response_type=str,
|
||||
)
|
||||
|
||||
function_call, approval_request = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage]
|
||||
request_function_call = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert function_call.arguments == {
|
||||
"request_id": "request_123",
|
||||
"data": {"target_agent": "helper", "reason": "overflow"},
|
||||
}
|
||||
assert approval_request.function_call is function_call
|
||||
assert json.loads(json.dumps(function_call.arguments)) == function_call.arguments
|
||||
assert request_function_call.call_id == "request_123"
|
||||
assert isinstance(request_function_call.arguments, dict)
|
||||
assert request_function_call.arguments.get("request_event") is not None
|
||||
request_event = request_function_call.arguments["request_event"]
|
||||
assert request_event.get("type") == "request_info"
|
||||
assert request_event.get("request_id") == "request_123"
|
||||
assert request_event.get("source_executor_id") == "executor1"
|
||||
assert deserialize_type(request_event.get("response_type")) is str
|
||||
assert request_event.get("data") == HandoffRequest(target_agent="helper", reason="overflow")
|
||||
|
||||
deserialized_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(request_function_call.arguments)
|
||||
assert deserialized_args.request_id == "request_123"
|
||||
assert isinstance(deserialized_args.request_event, WorkflowEvent)
|
||||
assert deserialized_args.request_event.type == "request_info"
|
||||
assert deserialized_args.request_event.data == HandoffRequest(target_agent="helper", reason="overflow")
|
||||
assert deserialized_args.request_event.response_type is str
|
||||
|
||||
def test_process_request_info_event_passes_through_function_approval_request(self) -> None:
|
||||
"""If the event data is already a function approval request, it is forwarded unchanged.
|
||||
|
||||
Tool-approval requests emitted by an inner agent surface as ``Content``
|
||||
objects with ``user_input_request=True``. ``WorkflowAgent`` must not
|
||||
re-wrap these inside a synthesized ``request_info`` function call;
|
||||
instead it should return the original content as-is so callers can
|
||||
respond with a matching ``function_approval_response``.
|
||||
"""
|
||||
executor = SimpleExecutor(id="executor1", response_text="Response")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Passthrough Agent")
|
||||
|
||||
approval_id = "approval-passthrough-1"
|
||||
inner_function_call = Content.from_function_call(
|
||||
call_id="tool-call-1",
|
||||
name="delete_file",
|
||||
arguments={"path": "/tmp/x"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=approval_id,
|
||||
function_call=inner_function_call,
|
||||
)
|
||||
event = WorkflowEvent.request_info(
|
||||
request_id=approval_id,
|
||||
source_executor_id="executor1",
|
||||
request_data=approval_request,
|
||||
response_type=Content,
|
||||
)
|
||||
|
||||
result = agent._process_request_info_event(event) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# The original FunctionApprovalRequestContent is returned as-is — same
|
||||
# instance, with the original tool name preserved (NOT replaced by the
|
||||
# synthesized REQUEST_INFO_FUNCTION_NAME).
|
||||
assert result is approval_request
|
||||
assert result.type == "function_approval_request"
|
||||
assert result.id == approval_id
|
||||
assert result.user_input_request is True
|
||||
assert result.function_call is inner_function_call # type: ignore[attr-defined]
|
||||
assert result.function_call.name == "delete_file" # type: ignore[attr-defined]
|
||||
assert result.function_call.name != WorkflowAgent.REQUEST_INFO_FUNCTION_NAME # type: ignore[attr-defined]
|
||||
|
||||
def test_extract_function_responses_passes_through_approval_response_approved(self) -> None:
|
||||
"""A function_approval_response with approved=True is keyed by content.id and forwarded as-is.
|
||||
|
||||
After the refactor, ``WorkflowAgent`` no longer unwraps a synthesized
|
||||
``request_info`` function call from approval responses — the response
|
||||
content is routed straight back to the workflow under its own ``id``,
|
||||
which matches the pending request id surfaced by
|
||||
``_process_request_info_event``.
|
||||
"""
|
||||
executor = SimpleExecutor(id="executor1", response_text="Response")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Response Agent")
|
||||
|
||||
approval_id = "approval-response-approved-1"
|
||||
inner_function_call = Content.from_function_call(
|
||||
call_id="tool-call-1",
|
||||
name="delete_file",
|
||||
arguments={"path": "/tmp/x"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=approval_id,
|
||||
function_call=inner_function_call,
|
||||
)
|
||||
approval_response = approval_request.to_function_approval_response(approved=True) # type: ignore[attr-defined]
|
||||
message = Message(role="user", contents=[approval_response])
|
||||
|
||||
responses = agent._extract_function_responses([message]) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert set(responses.keys()) == {approval_id}
|
||||
assert responses[approval_id] is approval_response
|
||||
assert responses[approval_id].approved is True # type: ignore[attr-defined]
|
||||
|
||||
def test_extract_function_responses_passes_through_approval_response_denied(self) -> None:
|
||||
"""A function_approval_response with approved=False is forwarded the same way as an approval.
|
||||
|
||||
Only the ``approved`` flag changes — routing back to the workflow is
|
||||
identical for accept and reject paths.
|
||||
"""
|
||||
executor = SimpleExecutor(id="executor1", response_text="Response")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Response Agent")
|
||||
|
||||
approval_id = "approval-response-denied-1"
|
||||
inner_function_call = Content.from_function_call(
|
||||
call_id="tool-call-2",
|
||||
name="send_email",
|
||||
arguments={"to": "alice@example.com"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=approval_id,
|
||||
function_call=inner_function_call,
|
||||
)
|
||||
approval_response = approval_request.to_function_approval_response(approved=False) # type: ignore[attr-defined]
|
||||
message = Message(role="user", contents=[approval_response])
|
||||
|
||||
responses = agent._extract_function_responses([message]) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert set(responses.keys()) == {approval_id}
|
||||
assert responses[approval_id] is approval_response
|
||||
assert responses[approval_id].approved is False # type: ignore[attr-defined]
|
||||
|
||||
async def test_function_approval_request_flows_end_to_end_approved(self) -> None:
|
||||
"""End-to-end: an executor emits a function_approval_request, the agent
|
||||
forwards it unchanged, and an ``approved=True`` response resumes the workflow.
|
||||
|
||||
This exercises the full pass-through path:
|
||||
``ctx.request_info(approval_content, ...)`` -> ``WorkflowAgent`` surfaces
|
||||
the original ``FunctionApprovalRequestContent`` -> caller responds with a
|
||||
``FunctionApprovalResponseContent`` -> ``WorkflowAgent`` routes it back
|
||||
to the workflow which delivers it to the executor's ``@response_handler``.
|
||||
"""
|
||||
approval_id = "e2e-approval-1"
|
||||
inner_function_call = Content.from_function_call(
|
||||
call_id="tool-call-e2e-1",
|
||||
name="delete_file",
|
||||
arguments={"path": "/tmp/x"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=approval_id,
|
||||
function_call=inner_function_call,
|
||||
)
|
||||
|
||||
class ApprovalRequestingExecutor(Executor):
|
||||
@handler
|
||||
async def handle_message(self, _: list[Message], ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(approval_request, Content, request_id=approval_id)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: Content,
|
||||
response: Content,
|
||||
ctx: WorkflowContext[Never, AgentResponse],
|
||||
) -> None:
|
||||
assert response.type == "function_approval_response"
|
||||
assert response.id == approval_id # type: ignore[attr-defined]
|
||||
approved = bool(response.approved) # type: ignore[attr-defined]
|
||||
tool_name = original_request.function_call.name # type: ignore[attr-defined]
|
||||
await ctx.yield_output(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text=f"{tool_name} approved={approved}")],
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
executor = ApprovalRequestingExecutor(id="approval_requester")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="E2E Approval Agent")
|
||||
|
||||
# First run: workflow pauses with the approval request.
|
||||
first = await agent.run("please delete it")
|
||||
assert isinstance(first, AgentResponse)
|
||||
|
||||
forwarded = next(
|
||||
(
|
||||
c
|
||||
for m in first.messages
|
||||
for c in m.contents
|
||||
if c.type == "function_approval_request" and c.id == approval_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert forwarded is approval_request, "Approval request must surface unchanged"
|
||||
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert approval_id in pending
|
||||
|
||||
# Respond with approved=True.
|
||||
approval_response = approval_request.to_function_approval_response(approved=True) # type: ignore[attr-defined]
|
||||
final = await agent.run(Message(role="user", contents=[approval_response]))
|
||||
|
||||
assert isinstance(final, AgentResponse)
|
||||
final_text = " ".join(m.text or "" for m in final.messages)
|
||||
assert "delete_file approved=True" in final_text
|
||||
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert approval_id not in pending
|
||||
|
||||
async def test_function_approval_request_flows_end_to_end_denied(self) -> None:
|
||||
"""End-to-end denied path: ``approved=False`` is delivered to the executor's
|
||||
response handler so the workflow can branch on the rejection."""
|
||||
approval_id = "e2e-approval-deny-1"
|
||||
inner_function_call = Content.from_function_call(
|
||||
call_id="tool-call-e2e-deny-1",
|
||||
name="send_email",
|
||||
arguments={"to": "alice@example.com"},
|
||||
)
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id=approval_id,
|
||||
function_call=inner_function_call,
|
||||
)
|
||||
|
||||
class ApprovalRequestingExecutor(Executor):
|
||||
@handler
|
||||
async def handle_message(self, _: list[Message], ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(approval_request, Content, request_id=approval_id)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: Content,
|
||||
response: Content,
|
||||
ctx: WorkflowContext[Never, AgentResponse],
|
||||
) -> None:
|
||||
assert response.type == "function_approval_response"
|
||||
assert response.id == approval_id # type: ignore[attr-defined]
|
||||
approved = bool(response.approved) # type: ignore[attr-defined]
|
||||
tool_name = original_request.function_call.name # type: ignore[attr-defined]
|
||||
await ctx.yield_output(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text=f"{tool_name} approved={approved}")],
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
executor = ApprovalRequestingExecutor(id="approval_requester_deny")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="E2E Approval Deny Agent")
|
||||
|
||||
first = await agent.run("please send")
|
||||
assert isinstance(first, AgentResponse)
|
||||
forwarded = next(
|
||||
(
|
||||
c
|
||||
for m in first.messages
|
||||
for c in m.contents
|
||||
if c.type == "function_approval_request" and c.id == approval_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert forwarded is approval_request
|
||||
|
||||
# Respond with approved=False.
|
||||
approval_response = approval_request.to_function_approval_response(approved=False) # type: ignore[attr-defined]
|
||||
final = await agent.run(Message(role="user", contents=[approval_response]))
|
||||
|
||||
assert isinstance(final, AgentResponse)
|
||||
final_text = " ".join(m.text or "" for m in final.messages)
|
||||
assert "send_email approved=False" in final_text
|
||||
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert approval_id not in pending
|
||||
|
||||
async def test_request_info_non_approval_flows_end_to_end(self) -> None:
|
||||
"""End-to-end: when request data is not a function approval content, the
|
||||
agent surfaces a synthesized ``function_call`` (name=REQUEST_INFO_FUNCTION_NAME)
|
||||
and routes a matching ``function_result`` back to the executor.
|
||||
"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class HandoffRequestingExecutor(Executor):
|
||||
@handler
|
||||
async def handle_message(self, _: list[Message], ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(
|
||||
HandoffRequest(target_agent="helper", reason="overflow"),
|
||||
str,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: HandoffRequest,
|
||||
response: str,
|
||||
ctx: WorkflowContext[Never, AgentResponse],
|
||||
) -> None:
|
||||
captured["original"] = original_request
|
||||
captured["response"] = response
|
||||
await ctx.yield_output(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text(text=f"handoff to {original_request.target_agent}: {response}")
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
executor = HandoffRequestingExecutor(id="handoff_requester")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="E2E Handoff Agent")
|
||||
|
||||
# First run: workflow pauses with a synthesized request_info function_call.
|
||||
first = await agent.run("start handoff")
|
||||
assert isinstance(first, AgentResponse)
|
||||
|
||||
function_call = next(
|
||||
(
|
||||
c
|
||||
for m in first.messages
|
||||
for c in m.contents
|
||||
if c.type == "function_call" and c.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert function_call is not None, "Expected a synthesized request_info function_call"
|
||||
assert function_call.call_id is not None
|
||||
assert isinstance(function_call.arguments, dict)
|
||||
request_id = function_call.arguments["request_id"]
|
||||
assert function_call.call_id == request_id
|
||||
request_payload = function_call.arguments["request_event"]
|
||||
assert request_payload.get("type") == "request_info"
|
||||
assert request_payload.get("data") == HandoffRequest(target_agent="helper", reason="overflow")
|
||||
|
||||
deserialized_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(function_call.arguments)
|
||||
assert deserialized_args.request_id == request_id
|
||||
assert isinstance(deserialized_args.request_event, WorkflowEvent)
|
||||
assert deserialized_args.request_event.type == "request_info"
|
||||
assert deserialized_args.request_event.data == HandoffRequest(target_agent="helper", reason="overflow")
|
||||
assert deserialized_args.request_event.response_type is str
|
||||
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert request_id in pending
|
||||
|
||||
# Respond with a function_result keyed by the call_id.
|
||||
function_result = Content.from_function_result(call_id=request_id, result="ok-do-it")
|
||||
final = await agent.run(Message(role="user", contents=[function_result]))
|
||||
|
||||
assert isinstance(final, AgentResponse)
|
||||
final_text = " ".join(m.text or "" for m in final.messages)
|
||||
assert "handoff to helper: ok-do-it" in final_text
|
||||
|
||||
# The executor's response handler received the original request and the response.
|
||||
assert isinstance(captured.get("original"), HandoffRequest)
|
||||
assert captured["original"].target_agent == "helper"
|
||||
assert captured["response"] == "ok-do-it"
|
||||
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert request_id not in pending
|
||||
|
||||
def test_workflow_as_agent_method(self) -> None:
|
||||
"""Test that Workflow.as_agent() creates a properly configured WorkflowAgent."""
|
||||
@@ -1592,3 +1946,406 @@ class TestWorkflowAgentMergeUpdates:
|
||||
|
||||
# Order: text (user), text (assistant), function_result (orphan at end)
|
||||
assert content_types == ["text", "text", "function_result"]
|
||||
|
||||
|
||||
class _ToolApprovalMockAgent(SupportsAgentRun):
|
||||
"""Mock agent whose first run returns a FunctionApprovalRequestContent.
|
||||
|
||||
Subsequent runs (after receiving an approval response in the input messages)
|
||||
return a final assistant text response that echoes the approved arguments.
|
||||
|
||||
This mirrors a real agent whose tool invocation requires user approval.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
tool_name: str = "delete_file",
|
||||
tool_arguments: dict[str, Any] | None = None,
|
||||
approval_request_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
self.id = str(uuid.uuid4())
|
||||
self.name = name
|
||||
self.description: str | None = None
|
||||
self._tool_name = tool_name
|
||||
self._tool_arguments = tool_arguments or {"path": "/tmp/example"}
|
||||
# Pre-allocated request ids so the test can verify what the WorkflowAgent forwards.
|
||||
self._approval_request_ids: list[str] = list(approval_request_ids) if approval_request_ids else []
|
||||
self.run_count = 0
|
||||
# Inputs received on the most recent (continuation) run, for assertions.
|
||||
self.last_run_messages: list[Message] = []
|
||||
|
||||
def create_session(self, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def get_session(self, *, service_session_id: str, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def _next_request_id(self) -> str:
|
||||
if self._approval_request_ids:
|
||||
return self._approval_request_ids.pop(0)
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _build_approval_request(self) -> Content:
|
||||
request_id = self._next_request_id()
|
||||
function_call = Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self._tool_name,
|
||||
arguments=self._tool_arguments,
|
||||
)
|
||||
return Content.from_function_approval_request(id=request_id, function_call=function_call)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
return self._run_stream(messages=messages, session=session, **kwargs)
|
||||
return self._run(messages=messages, session=session, **kwargs)
|
||||
|
||||
def _normalize(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None,
|
||||
) -> list[Message]:
|
||||
if messages is None:
|
||||
return []
|
||||
if isinstance(messages, str):
|
||||
return [Message(role="user", contents=[Content.from_text(text=messages)])]
|
||||
if isinstance(messages, Message):
|
||||
return [messages]
|
||||
if isinstance(messages, Content):
|
||||
return [Message(role="user", contents=[messages])]
|
||||
result: list[Message] = []
|
||||
for item in messages:
|
||||
if isinstance(item, Message):
|
||||
result.append(item)
|
||||
elif isinstance(item, Content):
|
||||
result.append(Message(role="user", contents=[item]))
|
||||
else:
|
||||
result.append(Message(role="user", contents=[Content.from_text(text=item)]))
|
||||
return result
|
||||
|
||||
def _approval_responses_in(self, messages: list[Message]) -> list[Content]:
|
||||
approvals: list[Content] = []
|
||||
for msg in messages:
|
||||
for content in msg.contents:
|
||||
if content.type == "function_approval_response":
|
||||
approvals.append(content)
|
||||
return approvals
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
normalized = self._normalize(messages)
|
||||
self.last_run_messages = normalized
|
||||
self.run_count += 1
|
||||
|
||||
approvals = self._approval_responses_in(normalized)
|
||||
if approvals:
|
||||
# Continuation: reflect approved arguments in the final response text.
|
||||
approved_text = "; ".join(
|
||||
f"approved={a.approved} id={a.id}" # type: ignore[attr-defined]
|
||||
for a in approvals
|
||||
)
|
||||
return AgentResponse(messages=[Message("assistant", [Content.from_text(text=f"done ({approved_text})")])])
|
||||
|
||||
# First run: ask for tool approval.
|
||||
approval = self._build_approval_request()
|
||||
return AgentResponse(messages=[Message("assistant", [approval])])
|
||||
|
||||
def _run_stream(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
normalized = self._normalize(messages)
|
||||
self.last_run_messages = normalized
|
||||
self.run_count += 1
|
||||
approvals = self._approval_responses_in(normalized)
|
||||
|
||||
async def _iter():
|
||||
if approvals:
|
||||
approved_text = "; ".join(
|
||||
f"approved={a.approved} id={a.id}" # type: ignore[attr-defined]
|
||||
for a in approvals
|
||||
)
|
||||
yield AgentResponseUpdate(
|
||||
contents=[Content.from_text(text=f"done ({approved_text})")],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
)
|
||||
return
|
||||
approval = self._build_approval_request()
|
||||
yield AgentResponseUpdate(
|
||||
contents=[approval],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
)
|
||||
|
||||
return ResponseStream(_iter(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
|
||||
class TestWorkflowAgentToolApproval:
|
||||
"""Tests for tool-approval requests bubbling through WorkflowAgent.
|
||||
|
||||
Covers the case where a workflow contains an AgentExecutor whose underlying
|
||||
agent emits a FunctionApprovalRequestContent (tool needing user approval).
|
||||
The WorkflowAgent must:
|
||||
* forward the original FunctionApprovalRequestContent unchanged (no
|
||||
wrapping inside a synthesized 'request_info' function call), and
|
||||
* route a subsequent FunctionApprovalResponseContent back to the
|
||||
AgentExecutor so the agent can resume.
|
||||
"""
|
||||
|
||||
def _find_approval_request(
|
||||
self,
|
||||
contents: Sequence[Content],
|
||||
tool_name: str,
|
||||
) -> Content | None:
|
||||
for content in contents:
|
||||
if (
|
||||
content.type == "function_approval_request"
|
||||
and getattr(content.function_call, "name", None) == tool_name # type: ignore[attr-defined]
|
||||
):
|
||||
return content
|
||||
return None
|
||||
|
||||
async def test_tool_approval_request_forwarded_unchanged(self) -> None:
|
||||
"""The agent's FunctionApprovalRequestContent surfaces verbatim (not re-wrapped)."""
|
||||
approval_id = "approval-abc-123"
|
||||
mock_agent = _ToolApprovalMockAgent(
|
||||
name="approval-agent",
|
||||
tool_name="delete_file",
|
||||
tool_arguments={"path": "/tmp/secret.txt"},
|
||||
approval_request_ids=[approval_id],
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, mock_agent).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Test Agent")
|
||||
|
||||
result = await agent.run("please delete the file")
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
|
||||
# Locate the approval request emitted by the WorkflowAgent.
|
||||
all_contents: list[Content] = [c for m in result.messages for c in m.contents]
|
||||
approval = self._find_approval_request(all_contents, tool_name="delete_file")
|
||||
assert approval is not None, "WorkflowAgent did not forward the tool approval request"
|
||||
|
||||
# The id and inner function_call must match what the underlying agent produced
|
||||
# — i.e. the WorkflowAgent must NOT have re-wrapped it inside a synthesized
|
||||
# 'request_info' approval request.
|
||||
assert approval.id == approval_id
|
||||
function_call = approval.function_call # type: ignore[attr-defined]
|
||||
assert function_call is not None
|
||||
assert function_call.name == "delete_file"
|
||||
assert function_call.name != WorkflowAgent.REQUEST_INFO_FUNCTION_NAME
|
||||
assert function_call.arguments == {"path": "/tmp/secret.txt"}
|
||||
|
||||
# The agent must be paused awaiting the approval response.
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert approval_id in pending
|
||||
|
||||
async def test_tool_approval_request_forwarded_unchanged_streaming(self) -> None:
|
||||
"""Streaming variant: the approval request is forwarded as-is in updates."""
|
||||
approval_id = "approval-stream-1"
|
||||
mock_agent = _ToolApprovalMockAgent(
|
||||
name="approval-agent-stream",
|
||||
tool_name="send_email",
|
||||
tool_arguments={"to": "alice@example.com"},
|
||||
approval_request_ids=[approval_id],
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, mock_agent).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Stream Agent")
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("hi", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
approval_updates = [u for u in updates if any(c.type == "function_approval_request" for c in u.contents)]
|
||||
assert approval_updates, "Streaming did not surface a tool approval request"
|
||||
|
||||
approval = self._find_approval_request(approval_updates[-1].contents, tool_name="send_email")
|
||||
assert approval is not None
|
||||
assert approval.id == approval_id
|
||||
function_call = approval.function_call # type: ignore[attr-defined]
|
||||
assert function_call is not None
|
||||
assert function_call.name == "send_email"
|
||||
assert function_call.name != WorkflowAgent.REQUEST_INFO_FUNCTION_NAME
|
||||
assert function_call.arguments == {"to": "alice@example.com"}
|
||||
|
||||
async def test_tool_approval_response_resumes_agent(self) -> None:
|
||||
"""Sending the approval response back resumes the agent and clears pending requests."""
|
||||
approval_id = "approval-resume-1"
|
||||
mock_agent = _ToolApprovalMockAgent(
|
||||
name="approval-resume-agent",
|
||||
tool_name="delete_file",
|
||||
tool_arguments={"path": "/tmp/x"},
|
||||
approval_request_ids=[approval_id],
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, mock_agent).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Resume Agent")
|
||||
|
||||
first_result = await agent.run("delete it")
|
||||
approval = self._find_approval_request(
|
||||
[c for m in first_result.messages for c in m.contents],
|
||||
tool_name="delete_file",
|
||||
)
|
||||
assert approval is not None
|
||||
assert mock_agent.run_count == 1
|
||||
|
||||
# Build the approval response. NOTE: the inner function_call's name is the
|
||||
# original tool name ('delete_file'), NOT 'request_info'. This exercises the
|
||||
# branch in WorkflowAgent._extract_function_responses that routes raw
|
||||
# tool-approval responses straight through using content.id.
|
||||
approval_response = approval.to_function_approval_response(approved=True) # type: ignore[attr-defined]
|
||||
response_message = Message(role="user", contents=[approval_response])
|
||||
|
||||
final_result = await agent.run(response_message)
|
||||
assert isinstance(final_result, AgentResponse)
|
||||
|
||||
# The mock agent should have been invoked a second time and seen the
|
||||
# approval response in its inputs.
|
||||
assert mock_agent.run_count == 2
|
||||
approvals_seen = [
|
||||
c for m in mock_agent.last_run_messages for c in m.contents if c.type == "function_approval_response"
|
||||
]
|
||||
assert len(approvals_seen) == 1
|
||||
assert approvals_seen[0].id == approval_id # type: ignore[attr-defined]
|
||||
assert approvals_seen[0].approved is True # type: ignore[attr-defined]
|
||||
|
||||
# The pending approval should now be cleared.
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert approval_id not in pending
|
||||
|
||||
# The final assistant message reflects the resumption.
|
||||
final_text = " ".join(m.text or "" for m in final_result.messages)
|
||||
assert "done" in final_text
|
||||
assert approval_id in final_text
|
||||
|
||||
async def test_tool_approval_response_rejected_resumes_agent(self) -> None:
|
||||
"""Rejection path: ``approved=False`` is forwarded to the inner agent and clears the pending request.
|
||||
|
||||
The WorkflowAgent must route a rejection response back to the paused
|
||||
``AgentExecutor`` exactly the same way as an approval — only the
|
||||
``approved`` flag differs. The inner agent decides what to do with it.
|
||||
"""
|
||||
approval_id = "approval-reject-1"
|
||||
mock_agent = _ToolApprovalMockAgent(
|
||||
name="approval-reject-agent",
|
||||
tool_name="delete_file",
|
||||
tool_arguments={"path": "/tmp/x"},
|
||||
approval_request_ids=[approval_id],
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, mock_agent).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Reject Agent")
|
||||
|
||||
first_result = await agent.run("delete it")
|
||||
approval = self._find_approval_request(
|
||||
[c for m in first_result.messages for c in m.contents],
|
||||
tool_name="delete_file",
|
||||
)
|
||||
assert approval is not None
|
||||
assert mock_agent.run_count == 1
|
||||
|
||||
# Reject the tool invocation.
|
||||
approval_response = approval.to_function_approval_response(approved=False) # type: ignore[attr-defined]
|
||||
response_message = Message(role="user", contents=[approval_response])
|
||||
|
||||
final_result = await agent.run(response_message)
|
||||
assert isinstance(final_result, AgentResponse)
|
||||
|
||||
# The inner agent must have been resumed and seen ``approved=False``.
|
||||
assert mock_agent.run_count == 2
|
||||
approvals_seen = [
|
||||
c for m in mock_agent.last_run_messages for c in m.contents if c.type == "function_approval_response"
|
||||
]
|
||||
assert len(approvals_seen) == 1
|
||||
assert approvals_seen[0].id == approval_id # type: ignore[attr-defined]
|
||||
assert approvals_seen[0].approved is False # type: ignore[attr-defined]
|
||||
|
||||
# Pending approval cleared regardless of approve/reject.
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
assert approval_id not in pending
|
||||
|
||||
# The final assistant message reflects the rejection.
|
||||
final_text = " ".join(m.text or "" for m in final_result.messages)
|
||||
assert "approved=False" in final_text
|
||||
assert approval_id in final_text
|
||||
|
||||
async def test_tool_approval_request_id_matches_pending_request(self) -> None:
|
||||
"""The approval request id surfaced by WorkflowAgent matches the workflow's pending request id.
|
||||
|
||||
This guards the AgentExecutor change that forwards
|
||||
request_id=user_input_request.id to ctx.request_info(...), which is what
|
||||
allows the response routed back via WorkflowAgent to resolve the pending
|
||||
request without an id-mismatch error.
|
||||
"""
|
||||
approval_id = "approval-id-match-1"
|
||||
mock_agent = _ToolApprovalMockAgent(
|
||||
name="approval-id-match-agent",
|
||||
approval_request_ids=[approval_id],
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, mock_agent).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Approval Id Agent")
|
||||
|
||||
await agent.run("go")
|
||||
|
||||
pending = await workflow._runner_context.get_pending_request_info_events()
|
||||
# The agent's approval id is used as the workflow's pending request id.
|
||||
assert list(pending.keys()) == [approval_id]
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the ``Workflow.status`` property."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework._workflows._executor import Executor as _Executor
|
||||
from agent_framework._workflows._request_info_mixin import RequestInfoMixin
|
||||
|
||||
|
||||
class PassThroughExecutor(Executor):
|
||||
"""Executor that yields its input as a workflow output and stops."""
|
||||
|
||||
@handler
|
||||
async def passthrough(self, msg: str, ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.yield_output(msg)
|
||||
|
||||
|
||||
class FailingExecutor(Executor):
|
||||
"""Executor that raises at runtime to drive the FAILED status."""
|
||||
|
||||
@handler
|
||||
async def fail(self, msg: int, ctx: WorkflowContext) -> None: # pragma: no cover - invoked via workflow
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ApprovalRequest:
|
||||
prompt: str
|
||||
request_id: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.request_id:
|
||||
import uuid
|
||||
|
||||
self.request_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
class ApprovalExecutor(_Executor, RequestInfoMixin):
|
||||
"""Executor that issues a single request_info call and finalizes on response."""
|
||||
|
||||
def __init__(self, id: str = "approval"):
|
||||
super().__init__(id=id)
|
||||
|
||||
@handler
|
||||
async def start(self, message: str, ctx: WorkflowContext[str, str]) -> None:
|
||||
await ctx.request_info(_ApprovalRequest(prompt=message), bool)
|
||||
|
||||
@response_handler
|
||||
async def on_response(
|
||||
self, original_request: _ApprovalRequest, approved: bool, ctx: WorkflowContext[str, str]
|
||||
) -> None:
|
||||
await ctx.yield_output(f"approved={approved}")
|
||||
|
||||
|
||||
def _build_passthrough_workflow() -> Workflow:
|
||||
executor = PassThroughExecutor(id="p")
|
||||
return WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
|
||||
|
||||
|
||||
def _build_failing_workflow() -> Workflow:
|
||||
# FailingExecutor has no workflow_output_types, so we leave designation
|
||||
# implicit; the deprecation warning is filtered at call sites that need it.
|
||||
return WorkflowBuilder(start_executor=FailingExecutor(id="f")).build()
|
||||
|
||||
|
||||
def _build_approval_workflow() -> Workflow:
|
||||
executor = ApprovalExecutor(id="approval")
|
||||
return WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
|
||||
|
||||
|
||||
async def test_status_default_is_idle_before_first_run():
|
||||
wf = _build_passthrough_workflow()
|
||||
assert wf.status is WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_status_is_idle_after_successful_run():
|
||||
wf = _build_passthrough_workflow()
|
||||
await wf.run("hello")
|
||||
assert wf.status is WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_status_is_failed_after_failure():
|
||||
wf = _build_failing_workflow()
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await wf.run(0)
|
||||
assert wf.status is WorkflowRunState.FAILED
|
||||
|
||||
|
||||
async def test_status_transitions_during_streaming_run():
|
||||
"""Workflow.status mirrors the most recent emitted status event."""
|
||||
wf = _build_passthrough_workflow()
|
||||
observed: list[WorkflowRunState] = []
|
||||
|
||||
async for event in wf.run("hi", stream=True):
|
||||
if isinstance(event, WorkflowEvent) and event.type == "status":
|
||||
# By the time a status event surfaces to the consumer, the property
|
||||
# must already reflect that state (updated in lockstep with emission).
|
||||
assert wf.status == event.state
|
||||
observed.append(event.state) # type: ignore
|
||||
|
||||
# IN_PROGRESS must precede IDLE; both must appear.
|
||||
assert WorkflowRunState.IN_PROGRESS in observed
|
||||
assert observed[-1] is WorkflowRunState.IDLE
|
||||
assert wf.status is WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_status_idle_with_pending_requests_then_resolves_to_idle():
|
||||
wf = _build_approval_workflow()
|
||||
|
||||
request_event: WorkflowEvent | None = None
|
||||
async for event in wf.run("please approve", stream=True):
|
||||
if isinstance(event, WorkflowEvent) and event.type == "request_info":
|
||||
request_event = event
|
||||
|
||||
assert request_event is not None
|
||||
assert wf.status is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
async for _ in wf.run(stream=True, responses={request_event.request_id: True}):
|
||||
pass
|
||||
|
||||
assert wf.status is WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_status_in_progress_pending_requests_observed_mid_run():
|
||||
"""While streaming, status reaches IN_PROGRESS_PENDING_REQUESTS after a request_info event."""
|
||||
wf = _build_approval_workflow()
|
||||
seen_states: list[WorkflowRunState] = []
|
||||
|
||||
async for event in wf.run("please approve", stream=True):
|
||||
if isinstance(event, WorkflowEvent) and event.type == "status":
|
||||
seen_states.append(event.state) # type: ignore
|
||||
|
||||
assert WorkflowRunState.IN_PROGRESS in seen_states
|
||||
assert WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS in seen_states
|
||||
assert seen_states[-1] is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
assert wf.status is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
@@ -191,6 +191,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw Foundry Agent client.
|
||||
|
||||
@@ -211,6 +212,8 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
compaction_strategy: Optional per-client compaction override.
|
||||
tokenizer: Optional tokenizer for compaction strategies.
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
settings = load_settings(
|
||||
FoundryAgentSettings,
|
||||
@@ -260,8 +263,11 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
openai_client_kwargs["default_headers"] = dict(default_headers)
|
||||
if allow_preview:
|
||||
openai_client_kwargs["agent_name"] = self.agent_name
|
||||
openai_client = self.project_client.get_openai_client(**openai_client_kwargs)
|
||||
if timeout is not None:
|
||||
openai_client = openai_client.with_options(timeout=timeout)
|
||||
super().__init__(
|
||||
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
|
||||
async_client=openai_client,
|
||||
default_headers=default_headers,
|
||||
instruction_role=instruction_role,
|
||||
compaction_strategy=compaction_strategy,
|
||||
@@ -537,6 +543,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent client with full middleware support.
|
||||
|
||||
@@ -556,6 +563,8 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional sequence of middleware.
|
||||
function_invocation_configuration: Optional function invocation configuration.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
super().__init__(
|
||||
project_endpoint=project_endpoint,
|
||||
@@ -573,6 +582,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
@@ -625,6 +635,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
additional_properties: Mapping[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent.
|
||||
|
||||
@@ -657,6 +668,8 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: Optional agent-level in-run compaction override.
|
||||
tokenizer: Optional agent-level tokenizer override.
|
||||
additional_properties: Additional properties stored on the local agent wrapper.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
# Create the client
|
||||
actual_client_type = client_type or _FoundryAgentChatClient
|
||||
@@ -675,6 +688,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
"default_headers": default_headers,
|
||||
"env_file_path": env_file_path,
|
||||
"env_file_encoding": env_file_encoding,
|
||||
"timeout": timeout,
|
||||
}
|
||||
if function_invocation_configuration is not None:
|
||||
if not issubclass(actual_client_type, FunctionInvocationLayer):
|
||||
@@ -912,6 +926,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
additional_properties: Mapping[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a Foundry Agent with full middleware and telemetry.
|
||||
|
||||
@@ -958,6 +973,8 @@ class FoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy: Optional agent-level in-run compaction override.
|
||||
tokenizer: Optional agent-level tokenizer override.
|
||||
additional_properties: Additional properties stored on the local agent wrapper.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
"""
|
||||
super().__init__(
|
||||
project_endpoint=project_endpoint,
|
||||
@@ -983,4 +1000,5 @@ class FoundryAgent( # type: ignore[misc]
|
||||
compaction_strategy=compaction_strategy,
|
||||
tokenizer=tokenizer,
|
||||
additional_properties=additional_properties,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.7.0"
|
||||
version = "1.8.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,10 +23,10 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-openai>=1.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"agent-framework-openai>=1.8.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
"azure-ai-projects>=2.2.0,<3.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -109,9 +109,67 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() -> None:
|
||||
"""Test that timeout is applied via with_options without mutating the shared OpenAI client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=60.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged() -> None:
|
||||
"""Test that timeout=None does not call with_options and leaves the shared client intact."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_not_called()
|
||||
assert openai_client_mock.timeout == 5.0
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled() -> None:
|
||||
"""Test that timeout uses with_options even when allow_preview=True (hosted agent path)."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
allow_preview=True,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=120.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None:
|
||||
"""Test that as_agent() wraps the client in FoundryAgent using the same client class."""
|
||||
|
||||
@@ -552,9 +610,29 @@ def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_foundry_agent_chat_client_init_propagates_timeout() -> None:
|
||||
"""Test that _FoundryAgentChatClient calls with_options instead of mutating the shared client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
client = _FoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=45.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=45.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_creates_client() -> None:
|
||||
"""Test that RawFoundryAgent creates a client internally."""
|
||||
|
||||
@@ -629,6 +707,7 @@ def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
@@ -641,9 +720,47 @@ def test_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
assert "tokenizer" in signature.parameters
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert "timeout" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None:
|
||||
"""Test that FoundryAgent uses with_options instead of mutating the shared OpenAI client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
agent = FoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=90.0,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_called_once_with(timeout=90.0)
|
||||
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
|
||||
assert agent.client.client is openai_client_mock.with_options.return_value
|
||||
|
||||
|
||||
def test_foundry_agent_init_timeout_none_leaves_client_default() -> None:
|
||||
"""Test that FoundryAgent with timeout=None does not call with_options or mutate the client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
openai_client_mock = MagicMock()
|
||||
openai_client_mock.timeout = 5.0
|
||||
mock_project.get_openai_client.return_value = openai_client_mock
|
||||
|
||||
FoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
openai_client_mock.with_options.assert_not_called()
|
||||
assert openai_client_mock.timeout == 5.0
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None:
|
||||
"""Test that invalid client_type raises TypeError."""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import tempfile
|
||||
import threading
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
@@ -264,28 +264,73 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
|
||||
|
||||
# Foundry Toolbox Auth integration
|
||||
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
|
||||
CONSENT_ERROR_CODE = -32007
|
||||
CONSENT_ERROR_CODE = -32006
|
||||
|
||||
|
||||
def consent_url_from_error(exc: BaseException) -> str | None:
|
||||
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
|
||||
@dataclass
|
||||
class ConsentError:
|
||||
name: str
|
||||
consent_url: str
|
||||
|
||||
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
|
||||
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
|
||||
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
|
||||
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
|
||||
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
|
||||
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
|
||||
|
||||
def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None:
|
||||
"""Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
|
||||
The consent URL(s) extracted from the error, or ``None`` if no consent error was found.
|
||||
"""
|
||||
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
|
||||
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
|
||||
return inner_exception.error.message
|
||||
# Parse the error message
|
||||
# The error message is structured with the following format:
|
||||
# "tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {"errors":[{"name": ..."
|
||||
# where the second part is a JSON string that can be deserialized into an object with the following shape:
|
||||
# ruff: disable[ERA001]
|
||||
# {
|
||||
# "errors" : [
|
||||
# {
|
||||
# "name": "Name of the MCP tool that requires consent",
|
||||
# "type" : "mcp",
|
||||
# "error": {
|
||||
# "code": "CONSENT_REQUIRED",
|
||||
# "message": consent_url,
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ruff: enable[ERA001]
|
||||
try:
|
||||
consent_errors: list[ConsentError] = []
|
||||
error_message_start = inner_exception.error.message.find("{")
|
||||
if error_message_start == -1:
|
||||
logger.warning("Consent error message does not contain JSON: %s", inner_exception.error.message)
|
||||
return None
|
||||
consent_details_json = inner_exception.error.message[error_message_start:]
|
||||
consent_details = json.loads(consent_details_json)
|
||||
if "errors" not in consent_details or not isinstance(consent_details["errors"], list):
|
||||
logger.warning("Consent error message JSON does not contain 'errors' list: %s", consent_details_json)
|
||||
return None
|
||||
for error in consent_details["errors"]:
|
||||
if (
|
||||
isinstance(error, dict)
|
||||
and error.get("type") == "mcp" # type: ignore
|
||||
and "error" in error
|
||||
and isinstance(error["error"], dict)
|
||||
and error["error"].get("code") == "CONSENT_REQUIRED" # type: ignore
|
||||
and "message" in error["error"]
|
||||
):
|
||||
consent_url = error["error"]["message"] # type: ignore
|
||||
if isinstance(consent_url, str):
|
||||
consent_errors.append(ConsentError(name=error.get("name", "Unknown"), consent_url=consent_url)) # type: ignore
|
||||
else:
|
||||
logger.warning("Consent URL in error message is not a valid URL: %s", consent_url) # type: ignore
|
||||
if consent_errors:
|
||||
return consent_errors
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse consent details JSON: %s", inner_exception.error.message)
|
||||
return None
|
||||
|
||||
|
||||
@@ -448,18 +493,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
try:
|
||||
await self._ensure_agent_ready()
|
||||
except AgentFrameworkException as ex:
|
||||
consent_url = consent_url_from_error(ex)
|
||||
if consent_url is None:
|
||||
consent_errors = consent_url_from_error(ex)
|
||||
if consent_errors is None:
|
||||
raise
|
||||
logger.warning("OAuth consent required for Foundry MCP gateway.")
|
||||
oauth_item = OAuthConsentRequestOutputItem(
|
||||
id=IdGenerator.new_id("oacr"),
|
||||
consent_link=consent_url,
|
||||
server_label="Foundry Toolbox",
|
||||
)
|
||||
builder = response_event_stream.add_output_item(oauth_item.id)
|
||||
yield builder.emit_added(oauth_item)
|
||||
yield builder.emit_done(oauth_item)
|
||||
for consent_error in consent_errors:
|
||||
logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url)
|
||||
oauth_item = OAuthConsentRequestOutputItem(
|
||||
id=IdGenerator.new_id("oacr"),
|
||||
consent_link=consent_error.consent_url,
|
||||
server_label=consent_error.name,
|
||||
)
|
||||
builder = response_event_stream.add_output_item(oauth_item.id)
|
||||
yield builder.emit_added(oauth_item)
|
||||
yield builder.emit_done(oauth_item)
|
||||
yield response_event_stream.emit_completed()
|
||||
return
|
||||
|
||||
@@ -521,7 +567,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
by the hosting infrastructure or files will be preserved upon deactivation.
|
||||
"""
|
||||
input_items = await context.get_input_items()
|
||||
input_messages = await _items_to_messages(input_items)
|
||||
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
|
||||
is_streaming_request = request.stream is not None and request.stream is True
|
||||
|
||||
_, are_options_set = _to_chat_options(request)
|
||||
@@ -618,7 +664,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
checkpoint_storage=write_storage,
|
||||
)
|
||||
|
||||
async for item in _to_outputs_for_messages(response_event_stream, response.messages):
|
||||
async for item in _to_outputs_for_messages(
|
||||
response_event_stream,
|
||||
response.messages,
|
||||
approval_storage=self._approval_storage,
|
||||
):
|
||||
yield item
|
||||
|
||||
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
|
||||
@@ -639,7 +689,9 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
for event in tracker.handle(content):
|
||||
yield event
|
||||
if tracker.needs_async:
|
||||
async for item in _to_outputs(response_event_stream, content):
|
||||
async for item in _to_outputs(
|
||||
response_event_stream, content, approval_storage=self._approval_storage
|
||||
):
|
||||
yield item
|
||||
tracker.needs_async = False
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260528"
|
||||
version = "1.0.0a260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b7,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
|
||||
@@ -11,24 +11,33 @@ the registered _handle_create handler.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, overload
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
AgentExecutorRequest,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
Content,
|
||||
FileCheckpointStorage,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
RawAgent,
|
||||
ResponseStream,
|
||||
SupportsAgentRun,
|
||||
WorkflowAgent,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowMessage,
|
||||
executor,
|
||||
)
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from mcp import McpError
|
||||
@@ -39,6 +48,7 @@ from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import (
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage]
|
||||
CONSENT_ERROR_CODE,
|
||||
ConsentError,
|
||||
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
_item_to_message, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -101,7 +111,7 @@ def _make_agent(
|
||||
return agent
|
||||
|
||||
|
||||
def _make_server(agent: MagicMock, **kwargs: Any) -> ResponsesHostServer:
|
||||
def _make_server(agent: Any, **kwargs: Any) -> ResponsesHostServer:
|
||||
"""Create a ResponsesHostServer with an in-memory store."""
|
||||
return ResponsesHostServer(agent, store=InMemoryResponseProvider(), **kwargs)
|
||||
|
||||
@@ -2118,15 +2128,11 @@ class TestMultiTurnMixedContent:
|
||||
assert resp2.json()["status"] == "completed"
|
||||
|
||||
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
|
||||
mcp_call_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"
|
||||
]
|
||||
mcp_call_contents = [c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"]
|
||||
mcp_result_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result"
|
||||
]
|
||||
function_result_contents = [
|
||||
c for m in second_call_messages for c in m.contents if c.type == "function_result"
|
||||
]
|
||||
function_result_contents = [c for m in second_call_messages for c in m.contents if c.type == "function_result"]
|
||||
|
||||
assert len(mcp_call_contents) >= 1
|
||||
assert len(mcp_result_contents) >= 1
|
||||
@@ -3264,7 +3270,10 @@ class TestCheckpointContextPathValidation:
|
||||
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
|
||||
|
||||
|
||||
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
|
||||
def _make_consent_error(
|
||||
url: str = "https://consent.example.com/auth",
|
||||
name: str = "Foundry Toolbox",
|
||||
) -> Exception:
|
||||
"""Build an exception wrapping a Foundry MCP gateway consent error.
|
||||
|
||||
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
|
||||
@@ -3272,17 +3281,34 @@ def _make_consent_error(url: str = "https://consent.example.com/auth") -> Except
|
||||
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
|
||||
original error attached via ``inner_exception``. ``consent_url_from_error``
|
||||
then finds the wrapped ``McpError`` in ``exc.args``.
|
||||
|
||||
The McpError message uses the structured Foundry MCP gateway format:
|
||||
a human-readable prefix followed by a JSON document describing each
|
||||
failed tool source and its consent URL.
|
||||
"""
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
|
||||
payload = json.dumps({
|
||||
"errors": [
|
||||
{
|
||||
"name": name,
|
||||
"type": "mcp",
|
||||
"error": {
|
||||
"code": "CONSENT_REQUIRED",
|
||||
"message": url,
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
message = f"tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {payload}"
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=message))
|
||||
return ToolExecutionException("MCP consent required", inner_exception=inner)
|
||||
|
||||
|
||||
class TestConsentUrlFromError:
|
||||
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
|
||||
exc = _make_consent_error("https://example.com/consent")
|
||||
assert consent_url_from_error(exc) == "https://example.com/consent"
|
||||
exc = _make_consent_error("https://example.com/consent", name="my-tool")
|
||||
assert consent_url_from_error(exc) == [ConsentError(name="my-tool", consent_url="https://example.com/consent")]
|
||||
|
||||
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
|
||||
assert consent_url_from_error(Exception("boom")) is None
|
||||
@@ -3299,6 +3325,13 @@ class TestConsentUrlFromError:
|
||||
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
|
||||
assert consent_url_from_error(bare) is None
|
||||
|
||||
def test_returns_none_when_message_has_no_json(self) -> None:
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="no json here"))
|
||||
exc = ToolExecutionException("MCP consent required", inner_exception=inner)
|
||||
assert consent_url_from_error(exc) is None
|
||||
|
||||
|
||||
class TestAgentLifecycle:
|
||||
async def test_agent_entered_lazily_on_first_request(self) -> None:
|
||||
@@ -3445,3 +3478,498 @@ class TestOAuthConsentSurfacing:
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Workflow agent hosting (end-to-end)
|
||||
|
||||
|
||||
class _ToolApprovalWorkflowAgentMock(SupportsAgentRun):
|
||||
"""Inner agent for a hosted ``WorkflowAgent`` whose first run emits a
|
||||
``FunctionApprovalRequestContent`` and whose follow-up run (after
|
||||
receiving a ``FunctionApprovalResponseContent`` in its inputs) returns a
|
||||
final assistant text response.
|
||||
|
||||
Mirrors a real agent whose tool invocation requires user approval. Used
|
||||
here to exercise the full HTTP pipeline through ``ResponsesHostServer``
|
||||
when the hosted agent is a ``WorkflowAgent`` containing a tool-approval
|
||||
flow.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
tool_name: str = "delete_file",
|
||||
tool_arguments: dict[str, Any] | None = None,
|
||||
approval_request_ids: Sequence[str] | None = None,
|
||||
final_text: str = "done",
|
||||
) -> None:
|
||||
self.id = str(uuid.uuid4())
|
||||
self.name = name
|
||||
self.description: str | None = None
|
||||
self._tool_name = tool_name
|
||||
self._tool_arguments = tool_arguments or {"path": "/tmp/example"}
|
||||
self._approval_request_ids: list[str] = list(approval_request_ids) if approval_request_ids else []
|
||||
self._final_text = final_text
|
||||
self.run_count = 0
|
||||
self.last_run_messages: list[Message] = []
|
||||
|
||||
def create_session(self, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def get_session(self, *, service_session_id: str, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def _next_request_id(self) -> str:
|
||||
# Stable across calls: when the workflow checkpoint round-trips through
|
||||
# restore, ``AgentExecutor`` re-invokes the inner agent during replay.
|
||||
# We must surface the *same* approval request id on each invocation so
|
||||
# the workflow's pending-request id matches the id the test echoes
|
||||
# back as ``mcp_approval_response``.
|
||||
if self._approval_request_ids:
|
||||
return self._approval_request_ids[0]
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _build_approval_request(self) -> Content:
|
||||
request_id = self._next_request_id()
|
||||
function_call = Content.from_function_call(
|
||||
call_id=request_id,
|
||||
name=self._tool_name,
|
||||
arguments=self._tool_arguments,
|
||||
additional_properties={"server_label": "test_server"},
|
||||
)
|
||||
return Content.from_function_approval_request(id=request_id, function_call=function_call)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
return self._run_stream(messages=messages, **kwargs)
|
||||
return self._run(messages=messages, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None,
|
||||
) -> list[Message]:
|
||||
if messages is None:
|
||||
return []
|
||||
if isinstance(messages, str):
|
||||
return [Message(role="user", contents=[Content.from_text(text=messages)])]
|
||||
if isinstance(messages, Message):
|
||||
return [messages]
|
||||
if isinstance(messages, Content):
|
||||
return [Message(role="user", contents=[messages])]
|
||||
result: list[Message] = []
|
||||
for item in messages:
|
||||
if isinstance(item, Message):
|
||||
result.append(item)
|
||||
elif isinstance(item, Content):
|
||||
result.append(Message(role="user", contents=[item]))
|
||||
else:
|
||||
result.append(Message(role="user", contents=[Content.from_text(text=item)]))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _approval_responses_in(messages: list[Message]) -> list[Content]:
|
||||
return [c for m in messages for c in m.contents if c.type == "function_approval_response"]
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
normalized = self._normalize(messages)
|
||||
self.last_run_messages = normalized
|
||||
self.run_count += 1
|
||||
if self._approval_responses_in(normalized):
|
||||
return AgentResponse(messages=[Message("assistant", [Content.from_text(text=self._final_text)])])
|
||||
approval = self._build_approval_request()
|
||||
return AgentResponse(messages=[Message("assistant", [approval])])
|
||||
|
||||
def _run_stream(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
normalized = self._normalize(messages)
|
||||
self.last_run_messages = normalized
|
||||
self.run_count += 1
|
||||
approvals = self._approval_responses_in(normalized)
|
||||
|
||||
async def _iter() -> AsyncIterator[AgentResponseUpdate]:
|
||||
if approvals:
|
||||
yield AgentResponseUpdate(
|
||||
contents=[Content.from_text(text=self._final_text)],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
)
|
||||
return
|
||||
yield AgentResponseUpdate(
|
||||
contents=[self._build_approval_request()],
|
||||
role="assistant",
|
||||
author_name=self.name,
|
||||
)
|
||||
|
||||
return ResponseStream(_iter(), finalizer=AgentResponse.from_updates)
|
||||
|
||||
|
||||
def _build_text_workflow_agent(text: str) -> WorkflowAgent:
|
||||
"""Build a minimal ``WorkflowAgent`` whose inner agent emits a fixed text."""
|
||||
|
||||
class _TextAgent(SupportsAgentRun):
|
||||
def __init__(self, name: str, text: str) -> None:
|
||||
self.id = str(uuid.uuid4())
|
||||
self.name = name
|
||||
self.description: str | None = None
|
||||
self._text = text
|
||||
|
||||
def create_session(self, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def get_session(self, *, service_session_id: str, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: Any = ...,
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse[Any]]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: Any = ...,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = ...,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: Any = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
text = self._text
|
||||
name = self.name
|
||||
|
||||
async def _aresult() -> AgentResponse:
|
||||
return AgentResponse(messages=[Message("assistant", [Content.from_text(text=text)])])
|
||||
|
||||
async def _aiter() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(
|
||||
contents=[Content.from_text(text=text)],
|
||||
role="assistant",
|
||||
author_name=name,
|
||||
)
|
||||
|
||||
if stream:
|
||||
return ResponseStream(_aiter(), finalizer=AgentResponse.from_updates)
|
||||
return _aresult()
|
||||
|
||||
inner = _TextAgent("text-agent", text)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, inner).build()
|
||||
return WorkflowAgent(workflow=workflow, name="Text Workflow Agent")
|
||||
|
||||
|
||||
def _build_approval_workflow_agent(
|
||||
*,
|
||||
approval_request_id: str,
|
||||
tool_name: str = "delete_file",
|
||||
tool_arguments: dict[str, Any] | None = None,
|
||||
final_text: str = "done",
|
||||
) -> tuple[WorkflowAgent, _ToolApprovalWorkflowAgentMock]:
|
||||
"""Build a ``WorkflowAgent`` whose inner agent emits a tool approval request."""
|
||||
mock_agent = _ToolApprovalWorkflowAgentMock(
|
||||
name="approval-agent",
|
||||
tool_name=tool_name,
|
||||
tool_arguments=tool_arguments or {"path": "/tmp/secret.txt"},
|
||||
approval_request_ids=[approval_request_id],
|
||||
final_text=final_text,
|
||||
)
|
||||
|
||||
@executor
|
||||
async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=True))
|
||||
|
||||
workflow = WorkflowBuilder(start_executor=start).add_edge(start, mock_agent).build()
|
||||
workflow_agent = WorkflowAgent(workflow=workflow, name="Approval Workflow Agent")
|
||||
return workflow_agent, mock_agent
|
||||
|
||||
|
||||
class TestWorkflowAgentHosting:
|
||||
"""End-to-end HTTP tests for ``ResponsesHostServer`` hosting a ``WorkflowAgent``.
|
||||
|
||||
These tests drive ``_handle_inner_workflow`` through the ASGI stack:
|
||||
they exercise checkpoint write/restore (multi-turn) and the
|
||||
tool-approval round-trip path, which is the primary differentiator
|
||||
relative to the regular agent path.
|
||||
"""
|
||||
|
||||
async def test_basic_text_response(self) -> None:
|
||||
workflow_agent = _build_text_workflow_agent("hello from workflow")
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
resp = await _post(server, input_text="hi", stream=False)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
text_found = any(
|
||||
part.get("type") == "output_text" and part.get("text") == "hello from workflow"
|
||||
for item in body["output"]
|
||||
if item["type"] == "message"
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
assert text_found, f"Expected workflow output text in {body['output']}"
|
||||
|
||||
async def test_basic_text_response_streaming(self) -> None:
|
||||
workflow_agent = _build_text_workflow_agent("hello stream")
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
resp = await _post(server, input_text="hi", stream=True)
|
||||
assert resp.status_code == 200
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
assert "response.output_text.delta" in types
|
||||
text_done = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert any(e["data"]["text"] == "hello stream" for e in text_done)
|
||||
|
||||
async def test_non_streaming_emits_mcp_approval_request_and_persists_to_storage(self) -> None:
|
||||
workflow_agent, mock_agent = _build_approval_workflow_agent(approval_request_id="apr_wf_ns")
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
resp = await _post(server, stream=False)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
|
||||
approval_items = [it for it in body["output"] if it["type"] == "mcp_approval_request"]
|
||||
assert len(approval_items) == 1
|
||||
assert approval_items[0]["name"] == "delete_file"
|
||||
assert approval_items[0]["server_label"] == "test_server"
|
||||
approval_request_id = approval_items[0]["id"]
|
||||
|
||||
# The id surfaced over the wire is generated by the response stream
|
||||
# builder; the original approval ``Content`` (carrying the inner
|
||||
# ``function_call``) must be persisted under that id so the next
|
||||
# turn can reconstruct it.
|
||||
loaded = await server._approval_storage.load_approval_request( # pyright: ignore[reportPrivateUsage]
|
||||
approval_request_id
|
||||
)
|
||||
assert loaded.type == "function_approval_request"
|
||||
assert loaded.function_call.name == "delete_file" # type: ignore[attr-defined]
|
||||
assert mock_agent.run_count == 1
|
||||
|
||||
async def test_streaming_emits_mcp_approval_request_and_persists_to_storage(self) -> None:
|
||||
workflow_agent, mock_agent = _build_approval_workflow_agent(approval_request_id="apr_wf_st")
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
resp = await _post(server, stream=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
events = _parse_sse_events(resp.text)
|
||||
types = _sse_event_types(events)
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
|
||||
approval_request_id: str | None = None
|
||||
for e in events:
|
||||
if e["event"] != "response.output_item.added":
|
||||
continue
|
||||
item = e["data"].get("item") or {}
|
||||
if item.get("type") == "mcp_approval_request":
|
||||
approval_request_id = item.get("id")
|
||||
break
|
||||
assert approval_request_id is not None
|
||||
|
||||
loaded = await server._approval_storage.load_approval_request( # pyright: ignore[reportPrivateUsage]
|
||||
approval_request_id
|
||||
)
|
||||
assert loaded.type == "function_approval_request"
|
||||
assert mock_agent.run_count == 1
|
||||
|
||||
async def test_round_trip_approval_response_resumes_workflow_agent(self) -> None:
|
||||
"""Two-turn HTTP round-trip:
|
||||
|
||||
Turn 1 emits ``mcp_approval_request`` and writes a workflow
|
||||
checkpoint under the response id. Turn 2 sends the
|
||||
``mcp_approval_response`` with ``previous_response_id`` set, so the
|
||||
host restores the checkpoint, the WorkflowAgent routes the
|
||||
approval response back to the paused inner agent, and the inner
|
||||
agent emits the final assistant text.
|
||||
"""
|
||||
workflow_agent, mock_agent = _build_approval_workflow_agent(
|
||||
approval_request_id="apr_wf_rt",
|
||||
final_text="done with approval",
|
||||
)
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
first = await _post(server, stream=False)
|
||||
assert first.status_code == 200
|
||||
first_body = first.json()
|
||||
first_response_id = first_body["id"]
|
||||
approval_items = [it for it in first_body["output"] if it["type"] == "mcp_approval_request"]
|
||||
assert len(approval_items) == 1
|
||||
approval_request_id = approval_items[0]["id"]
|
||||
assert mock_agent.run_count == 1
|
||||
|
||||
second_payload: dict[str, Any] = {
|
||||
"model": "test-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "mcp_approval_response",
|
||||
"approval_request_id": approval_request_id,
|
||||
"approve": True,
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
"previous_response_id": first_response_id,
|
||||
}
|
||||
second = await _post_json(server, second_payload)
|
||||
assert second.status_code == 200
|
||||
second_body = second.json()
|
||||
assert second_body["status"] == "completed"
|
||||
|
||||
# The inner agent must have been resumed (restore replay + new turn).
|
||||
# Restore call is a no-op for the mock (no input); the new-turn call
|
||||
# delivers the approval response, so run_count grows by at least 1.
|
||||
assert mock_agent.run_count >= 2
|
||||
|
||||
# The final assistant text from the resumed inner agent surfaces in
|
||||
# the HTTP output.
|
||||
text_pieces = [
|
||||
part.get("text", "")
|
||||
for item in second_body["output"]
|
||||
if item["type"] == "message"
|
||||
for part in item.get("content", [])
|
||||
if part.get("type") == "output_text"
|
||||
]
|
||||
assert any("done with approval" in t for t in text_pieces), (
|
||||
f"expected resumed workflow output, got {second_body['output']}"
|
||||
)
|
||||
|
||||
# The new-turn invocation of the inner agent must have received the
|
||||
# approval response routed back through WorkflowAgent.
|
||||
approval_responses = [
|
||||
c for m in mock_agent.last_run_messages for c in m.contents if c.type == "function_approval_response"
|
||||
]
|
||||
assert len(approval_responses) == 1
|
||||
assert approval_responses[0].approved is True # type: ignore[attr-defined]
|
||||
|
||||
async def test_round_trip_approval_response_streaming(self) -> None:
|
||||
"""Streaming variant of the round-trip: turn 2 is requested with
|
||||
``stream=true`` and surfaces the resumed text as SSE events."""
|
||||
workflow_agent, mock_agent = _build_approval_workflow_agent(
|
||||
approval_request_id="apr_wf_rt_st",
|
||||
final_text="streamed-done",
|
||||
)
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
first = await _post(server, stream=False)
|
||||
first_body = first.json()
|
||||
first_response_id = first_body["id"]
|
||||
approval_request_id = next(it["id"] for it in first_body["output"] if it["type"] == "mcp_approval_request")
|
||||
|
||||
second = await _post_json(
|
||||
server,
|
||||
{
|
||||
"model": "test-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "mcp_approval_response",
|
||||
"approval_request_id": approval_request_id,
|
||||
"approve": True,
|
||||
}
|
||||
],
|
||||
"stream": True,
|
||||
"previous_response_id": first_response_id,
|
||||
},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
events = _parse_sse_events(second.text)
|
||||
types = _sse_event_types(events)
|
||||
assert types[0] == "response.created"
|
||||
assert types[-1] == "response.completed"
|
||||
|
||||
text_done = [e for e in events if e["event"] == "response.output_text.done"]
|
||||
assert any("streamed-done" in e["data"]["text"] for e in text_done)
|
||||
assert mock_agent.run_count >= 2
|
||||
|
||||
async def test_round_trip_approval_response_rejected(self) -> None:
|
||||
"""Sending ``approve=False`` must surface as ``approved=False`` to the
|
||||
inner agent on resume."""
|
||||
workflow_agent, mock_agent = _build_approval_workflow_agent(
|
||||
approval_request_id="apr_wf_reject",
|
||||
final_text="acknowledged",
|
||||
)
|
||||
server = _make_server(workflow_agent)
|
||||
|
||||
first = await _post(server, stream=False)
|
||||
first_body = first.json()
|
||||
first_response_id = first_body["id"]
|
||||
approval_request_id = next(it["id"] for it in first_body["output"] if it["type"] == "mcp_approval_request")
|
||||
|
||||
second = await _post_json(
|
||||
server,
|
||||
{
|
||||
"model": "test-model",
|
||||
"input": [
|
||||
{
|
||||
"type": "mcp_approval_response",
|
||||
"approval_request_id": approval_request_id,
|
||||
"approve": False,
|
||||
}
|
||||
],
|
||||
"stream": False,
|
||||
"previous_response_id": first_response_id,
|
||||
},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
|
||||
approval_responses = [
|
||||
c for m in mock_agent.last_run_messages for c in m.contents if c.type == "function_approval_response"
|
||||
]
|
||||
assert len(approval_responses) == 1
|
||||
assert approval_responses[0].approved is False # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -10,6 +10,10 @@ pip install agent-framework-gemini --pre
|
||||
|
||||
The Gemini integration enables Microsoft Agent Framework applications to call Google Gemini models with familiar chat abstractions, including streaming, tool/function calling, and structured output.
|
||||
|
||||
## Structured Output
|
||||
|
||||
Gemini structured output can be configured with either a Pydantic model in `response_format`, a JSON schema mapping in `response_format`, or a Gemini-specific `response_schema`. Declarative agents that define `outputSchema` pass that schema through `response_format`.
|
||||
|
||||
## Authentication
|
||||
|
||||
The connector supports both `google-genai` authentication modes.
|
||||
|
||||
@@ -109,8 +109,8 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to
|
||||
or ``types.Tool`` objects returned by ``get_code_interpreter_tool``, ``get_web_search_tool``,
|
||||
``get_mcp_tool``, ``get_file_search_tool``, or ``get_maps_grounding_tool``.
|
||||
tool_choice: How the model picks a tool. One of ``'auto'``, ``'none'``, or ``'required'``.
|
||||
response_format: Pydantic model type for structured JSON output. The response text is
|
||||
parsed into the model and exposed via ``ChatResponse.value``.
|
||||
response_format: Pydantic model type or JSON schema mapping for structured JSON output.
|
||||
The response text is parsed and exposed via ``ChatResponse.value``.
|
||||
instructions: Extra system-level instructions prepended to the system message.
|
||||
|
||||
Not supported, and passing these raises a type error:
|
||||
@@ -255,6 +255,29 @@ _OPTION_CONSUMED_KEYS: frozenset[str] = frozenset({
|
||||
|
||||
_OPTION_EXCLUDE_KEYS: frozenset[str] = _OPTION_EXPLICIT_KEYS | _OPTION_CONSUMED_KEYS
|
||||
|
||||
_JSON_SCHEMA_TYPES: frozenset[str] = frozenset({
|
||||
"array",
|
||||
"boolean",
|
||||
"integer",
|
||||
"null",
|
||||
"number",
|
||||
"object",
|
||||
"string",
|
||||
})
|
||||
|
||||
_JSON_SCHEMA_KEYWORDS: frozenset[str] = frozenset({
|
||||
"$defs",
|
||||
"additionalProperties",
|
||||
"allOf",
|
||||
"anyOf",
|
||||
"enum",
|
||||
"items",
|
||||
"oneOf",
|
||||
"properties",
|
||||
"required",
|
||||
"type",
|
||||
})
|
||||
|
||||
_FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = {
|
||||
"STOP": "stop",
|
||||
"MAX_TOKENS": "length",
|
||||
@@ -747,9 +770,13 @@ class RawGeminiChatClient(
|
||||
continue
|
||||
kwargs[_OPTION_TRANSLATIONS.get(key, key)] = value
|
||||
|
||||
if options.get("response_format") or options.get("response_schema"):
|
||||
response_format = options.get("response_format")
|
||||
response_schema = options.get("response_schema")
|
||||
if response_format is not None or response_schema is not None:
|
||||
kwargs["response_mime_type"] = "application/json"
|
||||
if schema := options.get("response_schema"):
|
||||
if response_schema is not None:
|
||||
kwargs["response_schema"] = response_schema
|
||||
elif (schema := self._extract_response_schema(response_format)) is not None:
|
||||
kwargs["response_schema"] = schema
|
||||
if tools := self._prepare_tools(options):
|
||||
kwargs["tools"] = tools
|
||||
@@ -762,6 +789,48 @@ class RawGeminiChatClient(
|
||||
|
||||
return types.GenerateContentConfig(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_schema(response_format: Any) -> dict[str, Any] | None:
|
||||
"""Extract a Gemini response schema from supported mapping response_format shapes."""
|
||||
if not isinstance(response_format, Mapping):
|
||||
return None
|
||||
mapping = cast("Mapping[str, Any]", response_format)
|
||||
|
||||
if (nested := RawGeminiChatClient._extract_response_schema(mapping.get("format"))) is not None:
|
||||
return nested
|
||||
|
||||
json_schema = mapping.get("json_schema")
|
||||
if isinstance(json_schema, Mapping):
|
||||
schema = cast("Mapping[str, Any]", json_schema).get("schema")
|
||||
if isinstance(schema, Mapping):
|
||||
return dict(cast("Mapping[str, Any]", schema))
|
||||
|
||||
schema = mapping.get("schema")
|
||||
if isinstance(schema, Mapping):
|
||||
return dict(cast("Mapping[str, Any]", schema))
|
||||
|
||||
if RawGeminiChatClient._is_json_schema_mapping(mapping):
|
||||
return dict(mapping)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_json_schema_mapping(value: Mapping[str, Any]) -> bool:
|
||||
"""Return True when a mapping appears to be a JSON Schema rather than a response-format envelope."""
|
||||
if not any(keyword in value for keyword in _JSON_SCHEMA_KEYWORDS):
|
||||
return False
|
||||
|
||||
schema_type = value.get("type")
|
||||
if schema_type is None:
|
||||
return True
|
||||
if isinstance(schema_type, str):
|
||||
return schema_type in _JSON_SCHEMA_TYPES
|
||||
if isinstance(schema_type, Sequence) and not isinstance(schema_type, (str, bytes)):
|
||||
entries = cast("Sequence[object]", schema_type)
|
||||
return all(isinstance(item, str) and item in _JSON_SCHEMA_TYPES for item in entries)
|
||||
|
||||
return False
|
||||
|
||||
def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None:
|
||||
"""Translate the framework tool list into Gemini API tool objects.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Content, FunctionTool, Message
|
||||
from agent_framework import Agent, Content, FunctionTool, Message
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -915,6 +915,20 @@ async def test_response_format_populates_value_on_chat_response() -> None:
|
||||
assert response.value == Reply(text="hello")
|
||||
|
||||
|
||||
async def test_response_format_mapping_populates_value_on_chat_response() -> None:
|
||||
"""When response_format is a JSON schema mapping, ChatResponse.value must parse the response text."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"text": "hello"}')]))
|
||||
schema = {"type": "object", "properties": {"text": {"type": "string"}}}
|
||||
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": schema},
|
||||
)
|
||||
|
||||
assert response.value == {"text": "hello"}
|
||||
|
||||
|
||||
async def test_response_schema_added_to_config() -> None:
|
||||
"""Sets both response_mime_type and the raw schema on the config when response_schema is given."""
|
||||
client, mock = _make_gemini_client()
|
||||
@@ -931,6 +945,284 @@ async def test_response_schema_added_to_config() -> None:
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_raw_json_schema_added_to_config() -> None:
|
||||
"""For declarative outputSchema, response_format may already be a raw JSON schema mapping."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "hello"}')]))
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string", "description": "The answer."}},
|
||||
"required": ["answer"],
|
||||
}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": schema},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_agent_default_options_response_format_raw_schema_added_to_config() -> None:
|
||||
"""Agent default_options is the path used by declarative outputSchema."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "hello"}')]))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}
|
||||
agent = Agent(client=client, default_options={"response_format": schema})
|
||||
|
||||
await agent.run("Hi")
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_complex_raw_json_schema_preserved() -> None:
|
||||
"""Nested declarative schemas should be forwarded without losing shape or constraints."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "ok"}')]))
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {"type": "string"},
|
||||
"citations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
},
|
||||
"required": ["source"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
await client.get_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content.from_text(
|
||||
"Summarize a long document while preserving citation metadata.\n" + ("context\n" * 128)
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
options={"response_format": schema},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_json_schema_envelope_added_to_config() -> None:
|
||||
"""OpenAI-style json_schema envelopes should still provide Gemini with the inner schema."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "hello"}')]))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": {"type": "json_schema", "json_schema": {"name": "Answer", "schema": schema}}},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_format_envelope_added_to_config() -> None:
|
||||
"""Responses-style format envelopes should also provide Gemini with the nested schema."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "hello"}')]))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": {"format": {"type": "json_schema", "name": "Answer", "schema": schema}}},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_direct_schema_key_added_to_config() -> None:
|
||||
"""Provider-normalized mappings with a direct schema key should be accepted."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "hello"}')]))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": {"schema": schema}},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_json_schema_envelope_preserves_empty_schema() -> None:
|
||||
"""An explicitly empty JSON schema is still a schema and should not be dropped as falsy."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")]))
|
||||
schema: dict[str, Any] = {}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": {"type": "json_schema", "json_schema": {"name": "AnyJson", "schema": schema}}},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_anyof_raw_schema_added_to_config() -> None:
|
||||
"""Raw schemas without a type should still be recognized when they use JSON Schema keywords."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='"ok"')]))
|
||||
schema = {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": schema},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_union_type_raw_schema_added_to_config() -> None:
|
||||
"""JSON Schema union type arrays should be treated as raw schemas."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "hello"}')]))
|
||||
schema = {"type": ["object", "null"], "properties": {"answer": {"type": "string"}}}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": schema},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_response_format_json_object_does_not_set_schema() -> None:
|
||||
"""A JSON-object response_format requests JSON output but is not itself a Gemini response schema."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")]))
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": {"type": "json_object"}},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema is None
|
||||
|
||||
|
||||
async def test_response_format_json_schema_without_inner_schema_does_not_set_schema() -> None:
|
||||
"""A json_schema envelope without a schema should not be mistaken for a raw JSON schema."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")]))
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": {"type": "json_schema", "json_schema": {"name": "MissingSchema"}}},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema is None
|
||||
|
||||
|
||||
async def test_response_schema_takes_precedence_over_response_format_schema() -> None:
|
||||
"""An explicit Gemini response_schema should win when both schema options are present."""
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")]))
|
||||
response_format_schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||
response_schema = {"type": "object", "properties": {"id": {"type": "integer"}}}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": response_format_schema, "response_schema": response_schema},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_schema == response_schema
|
||||
|
||||
|
||||
async def test_response_format_raw_schema_kept_with_tools() -> None:
|
||||
"""Structured output must still reach Gemini when function tools are present."""
|
||||
|
||||
def calculator(expression: str) -> str:
|
||||
"""Evaluate a simple expression."""
|
||||
return expression
|
||||
|
||||
tool = FunctionTool(name="calculator", func=calculator)
|
||||
client, mock = _make_gemini_client()
|
||||
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"answer": "4"}')]))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("What is 2 + 2?")])],
|
||||
options={"tools": [tool], "response_format": schema},
|
||||
)
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
|
||||
assert config.response_schema == schema
|
||||
assert config.tools is not None
|
||||
assert config.tools[0].function_declarations[0].name == "calculator"
|
||||
|
||||
|
||||
async def test_streaming_response_format_raw_schema_added_to_config() -> None:
|
||||
"""Streaming requests use the same config path and should also forward raw schema mappings."""
|
||||
client, mock = _make_gemini_client()
|
||||
chunks = [_make_response([_make_part(text='{"answer": "hello"}')], finish_reason="STOP")]
|
||||
mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
|
||||
|
||||
stream = client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": schema},
|
||||
stream=True,
|
||||
)
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
config: types.GenerateContentConfig = mock.aio.models.generate_content_stream.call_args.kwargs["config"]
|
||||
assert config.response_mime_type == "application/json"
|
||||
assert config.response_schema == schema
|
||||
|
||||
|
||||
async def test_streaming_response_format_mapping_populates_final_value() -> None:
|
||||
"""Streaming responses should preserve mapping response_format for final value parsing."""
|
||||
client, mock = _make_gemini_client()
|
||||
chunks = [_make_response([_make_part(text='{"answer": "hello"}')], finish_reason="STOP")]
|
||||
mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks))
|
||||
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
|
||||
|
||||
stream = client.get_response(
|
||||
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
|
||||
options={"response_format": schema},
|
||||
stream=True,
|
||||
)
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
final = await stream.get_final_response()
|
||||
assert final.value == {"answer": "hello"}
|
||||
|
||||
|
||||
async def test_streaming_response_format_passed_to_build_response_stream() -> None:
|
||||
"""Verifies that response_format is forwarded to _build_response_stream when streaming
|
||||
so that structured output parsing works correctly on the final assembled response.
|
||||
|
||||
@@ -37,9 +37,10 @@ from agent_framework.exceptions import AgentException
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
|
||||
try:
|
||||
from copilot import CopilotClient, CopilotSession, SubprocessConfig
|
||||
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
|
||||
from copilot import CopilotClient, CopilotSession, RuntimeConnection
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
from copilot.session import MCPServerConfig, PermissionRequestResult, ProviderConfig, SystemMessageConfig
|
||||
from copilot.session_events import PermissionRequest, SessionEvent, SessionEventType
|
||||
from copilot.tools import Tool as CopilotTool
|
||||
from copilot.tools import ToolInvocation, ToolResult
|
||||
except ImportError as _copilot_import_error:
|
||||
@@ -57,8 +58,10 @@ else:
|
||||
DEFAULT_TIMEOUT_SECONDS: float = 60.0
|
||||
"""Default timeout in seconds for Copilot requests."""
|
||||
|
||||
PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult]
|
||||
"""Type for permission request handlers."""
|
||||
PermissionHandlerType = Callable[
|
||||
[PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
|
||||
]
|
||||
"""Type for permission request handlers. Supports both sync and async callbacks."""
|
||||
|
||||
|
||||
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
|
||||
@@ -121,7 +124,7 @@ def _deny_all_permissions(
|
||||
_invocation: dict[str, str],
|
||||
) -> PermissionRequestResult:
|
||||
"""Default permission handler that denies all requests."""
|
||||
return PermissionRequestResult()
|
||||
return PermissionDecisionUserNotAvailable()
|
||||
|
||||
|
||||
class GitHubCopilotSettings(TypedDict, total=False):
|
||||
@@ -140,9 +143,9 @@ class GitHubCopilotSettings(TypedDict, total=False):
|
||||
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
|
||||
log_level: CLI log level.
|
||||
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
|
||||
copilot_home: Directory where the CLI stores session state, configuration,
|
||||
base_directory: Directory where the CLI stores session state, configuration,
|
||||
and other persistent data. Can be set via environment variable
|
||||
GITHUB_COPILOT_COPILOT_HOME. Defaults to ~/.copilot when not set.
|
||||
GITHUB_COPILOT_BASE_DIRECTORY. Defaults to ~/.copilot when not set.
|
||||
Only applicable when the SDK spawns the CLI process (ignored when
|
||||
connecting to an external server via a pre-configured client).
|
||||
"""
|
||||
@@ -151,7 +154,7 @@ class GitHubCopilotSettings(TypedDict, total=False):
|
||||
model: str | None
|
||||
timeout: float | None
|
||||
log_level: str | None
|
||||
copilot_home: str | None
|
||||
base_directory: str | None
|
||||
|
||||
|
||||
class GitHubCopilotOptions(TypedDict, total=False):
|
||||
@@ -314,7 +317,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
provider: ProviderConfig | None = opts.pop("provider", None)
|
||||
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
|
||||
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
|
||||
copilot_home = opts.pop("copilot_home", None)
|
||||
base_directory = opts.pop("base_directory", None)
|
||||
|
||||
self._settings = load_settings(
|
||||
GitHubCopilotSettings,
|
||||
@@ -323,7 +326,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
log_level=log_level,
|
||||
copilot_home=copilot_home,
|
||||
base_directory=base_directory,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
@@ -362,14 +365,16 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
if self._client is None:
|
||||
cli_path = self._settings.get("cli_path") or None
|
||||
log_level = self._settings.get("log_level") or None
|
||||
copilot_home = self._settings.get("copilot_home") or None
|
||||
base_directory = self._settings.get("base_directory") or None
|
||||
|
||||
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
|
||||
client_kwargs: dict[str, Any] = {}
|
||||
if cli_path:
|
||||
client_kwargs["connection"] = RuntimeConnection.for_stdio(path=cli_path)
|
||||
if log_level:
|
||||
subprocess_kwargs["log_level"] = log_level
|
||||
if copilot_home:
|
||||
subprocess_kwargs["copilot_home"] = copilot_home
|
||||
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))
|
||||
client_kwargs["log_level"] = log_level
|
||||
if base_directory:
|
||||
client_kwargs["base_directory"] = base_directory
|
||||
self._client = CopilotClient(**client_kwargs)
|
||||
|
||||
try:
|
||||
await self._client.start()
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0rc1"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,8 +23,8 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.6.0,<2",
|
||||
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# ruff: noqa: E402
|
||||
|
||||
import os
|
||||
import unittest.mock
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -20,9 +21,11 @@ from agent_framework import (
|
||||
ContextProvider,
|
||||
HistoryProvider,
|
||||
Message,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from copilot.generated.session_events import (
|
||||
from copilot.session import PermissionHandler
|
||||
from copilot.session_events import (
|
||||
Data,
|
||||
SessionEvent,
|
||||
SessionEventType,
|
||||
@@ -308,27 +311,27 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
)
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.cli_path == "/custom/path"
|
||||
assert call_args.log_level == "debug"
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert kwargs["connection"].path == "/custom/path"
|
||||
assert kwargs["log_level"] == "debug"
|
||||
|
||||
async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
|
||||
"""Test that copilot_home is passed through to SubprocessConfig."""
|
||||
async def test_start_passes_base_directory_to_client(self) -> None:
|
||||
"""Test that base_directory is passed through to CopilotClient."""
|
||||
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
mock_client.start = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"copilot_home": "/custom/copilot/home"}
|
||||
default_options={"base_directory": "/custom/copilot/home"}
|
||||
)
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.copilot_home == "/custom/copilot/home"
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert kwargs["base_directory"] == "/custom/copilot/home"
|
||||
|
||||
async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
|
||||
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
|
||||
async def test_start_base_directory_not_set_when_unspecified(self) -> None:
|
||||
"""Test that base_directory is not included in client kwargs when not specified."""
|
||||
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
mock_client.start = AsyncMock()
|
||||
@@ -337,14 +340,14 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
agent = GitHubCopilotAgent()
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.copilot_home is None
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert "base_directory" not in kwargs
|
||||
|
||||
async def test_start_copilot_home_from_env_variable(self) -> None:
|
||||
"""Test that copilot_home can be set via GITHUB_COPILOT_COPILOT_HOME env variable."""
|
||||
async def test_start_base_directory_from_env_variable(self) -> None:
|
||||
"""Test that base_directory can be set via GITHUB_COPILOT_BASE_DIRECTORY env variable."""
|
||||
with (
|
||||
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
|
||||
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
|
||||
patch.dict("os.environ", {"GITHUB_COPILOT_BASE_DIRECTORY": "/env/copilot/home"}),
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_client.start = AsyncMock()
|
||||
@@ -353,8 +356,8 @@ class TestGitHubCopilotAgentLifecycle:
|
||||
agent = GitHubCopilotAgent()
|
||||
await agent.start()
|
||||
|
||||
call_args = MockClient.call_args[0][0]
|
||||
assert call_args.copilot_home == "/env/copilot/home"
|
||||
kwargs = MockClient.call_args.kwargs
|
||||
assert kwargs["base_directory"] == "/env/copilot/home"
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentRun:
|
||||
@@ -1053,11 +1056,11 @@ class TestGitHubCopilotAgentSessionManagement:
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that resumed session config includes tools and permission handler."""
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionDecisionApproveOnce()
|
||||
|
||||
def my_tool(arg: str) -> str:
|
||||
"""A test tool."""
|
||||
@@ -1869,6 +1872,15 @@ class TestGitHubCopilotAgentErrorHandling:
|
||||
class TestGitHubCopilotAgentPermissions:
|
||||
"""Test cases for permission handling."""
|
||||
|
||||
def test_deny_all_permissions_returns_user_not_available(self) -> None:
|
||||
"""Test that the default deny handler returns PermissionDecisionUserNotAvailable."""
|
||||
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
|
||||
|
||||
from agent_framework_github_copilot._agent import _deny_all_permissions
|
||||
|
||||
result = _deny_all_permissions(MagicMock(), {})
|
||||
assert isinstance(result, PermissionDecisionUserNotAvailable)
|
||||
|
||||
def test_no_permission_handler_when_not_provided(self) -> None:
|
||||
"""Test that no handler is set when on_permission_request is not provided."""
|
||||
agent = GitHubCopilotAgent()
|
||||
@@ -1876,13 +1888,14 @@ class TestGitHubCopilotAgentPermissions:
|
||||
|
||||
def test_permission_handler_set_when_provided(self) -> None:
|
||||
"""Test that a handler is set when on_permission_request is provided."""
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
|
||||
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
if request.kind == "shell":
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
return PermissionDecisionApproveOnce()
|
||||
return PermissionDecisionDeniedInteractivelyByUser()
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
default_options={"on_permission_request": approve_shell}
|
||||
@@ -1895,13 +1908,14 @@ class TestGitHubCopilotAgentPermissions:
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
"""Test that session config includes permission handler when provided."""
|
||||
from copilot.generated.session_events import PermissionRequest
|
||||
from copilot.session import PermissionRequestResult
|
||||
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
|
||||
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
|
||||
def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
|
||||
if request.kind in ("shell", "read"):
|
||||
return PermissionRequestResult(kind="approved")
|
||||
return PermissionRequestResult(kind="denied-interactively-by-user")
|
||||
return PermissionDecisionApproveOnce()
|
||||
return PermissionDecisionDeniedInteractivelyByUser()
|
||||
|
||||
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
@@ -2705,3 +2719,163 @@ class TestGitHubCopilotAgentContextProviders:
|
||||
assert call_kwargs.get("tools") is not None
|
||||
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||
assert "load_skill" in tool_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — require COPILOT_GITHUB_TOKEN env var
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
skip_if_copilot_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("COPILOT_GITHUB_TOKEN", "") == "",
|
||||
reason="No COPILOT_GITHUB_TOKEN provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
return f"The weather in {location} is sunny with a high of 25C."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_simple_prompt_returns_response() -> None:
|
||||
"""Integration test: basic non-streaming response."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
response = await agent.run("What is 2 + 2? Answer with just the number.", session=session)
|
||||
|
||||
assert response is not None
|
||||
assert len(response.messages) > 0
|
||||
assert "4" in response.text
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_streaming_returns_updates() -> None:
|
||||
"""Integration test: streaming response yields updates."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
updates = []
|
||||
async for chunk in agent.run("Count from 1 to 5.", stream=True, session=session):
|
||||
updates.append(chunk)
|
||||
|
||||
assert len(updates) > 0
|
||||
full_text = "".join(u.text for u in updates if u.text)
|
||||
assert len(full_text) > 0
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_function_tool_invokes_tool() -> None:
|
||||
"""Integration test: function tool is invoked by the agent."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather agent. Use the get_weather tool to answer weather questions.",
|
||||
tools=[get_weather],
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
response = await agent.run("What's the weather like in Seattle?", session=session)
|
||||
|
||||
assert response is not None
|
||||
assert len(response.messages) > 0
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"])
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_session_maintains_context() -> None:
|
||||
"""Integration test: session maintains conversation context across turns."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
|
||||
response1 = await agent.run("My name is Alice.", session=session)
|
||||
assert response1 is not None
|
||||
|
||||
response2 = await agent.run("What is my name?", session=session)
|
||||
|
||||
assert response2 is not None
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_session_resume_continues_conversation() -> None:
|
||||
"""Integration test: session can be resumed by ID."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant. Keep your answers short.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session1 = agent.create_session()
|
||||
await agent.run("Remember this number: 42.", session=session1)
|
||||
|
||||
session_id = session1.service_session_id
|
||||
assert session_id is not None
|
||||
|
||||
session2 = AgentSession()
|
||||
session2.service_session_id = session_id
|
||||
|
||||
response = await agent.run("What number did I ask you to remember?", session=session2)
|
||||
|
||||
assert response is not None
|
||||
assert "42" in response.text
|
||||
|
||||
if agent._client:
|
||||
await agent._client.delete_session(session_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_copilot_integration_tests_disabled
|
||||
async def test_integration_run_with_shell_permissions_executes_command() -> None:
|
||||
"""Integration test: shell commands can be executed with permission handler."""
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful assistant that can execute shell commands.",
|
||||
default_options={"on_permission_request": PermissionHandler.approve_all},
|
||||
)
|
||||
|
||||
async with agent:
|
||||
session = agent.create_session()
|
||||
response = await agent.run("Run a shell command to print 'hello world'", session=session)
|
||||
|
||||
assert response is not None
|
||||
assert "hello" in response.text.lower()
|
||||
|
||||
if session.service_session_id and agent._client:
|
||||
await agent._client.delete_session(session.service_session_id)
|
||||
|
||||
@@ -8,29 +8,34 @@ This module provides ``Mem0ContextProvider``, built on the new
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypedDict
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from mem0 import AsyncMemory, AsyncMemoryClient
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import NotRequired, Self, TypedDict # pragma: no cover
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._agents import SupportsAgentRun
|
||||
|
||||
|
||||
class _MemorySearchResponse_v1_1(TypedDict):
|
||||
results: list[dict[str, Any]]
|
||||
relations: NotRequired[list[dict[str, Any]]]
|
||||
logger = logging.getLogger(__name__)
|
||||
MemoryRecord: TypeAlias = dict[str, object]
|
||||
|
||||
|
||||
_MemorySearchResponse_v2 = list[dict[str, Any]]
|
||||
class SearchResults(TypedDict):
|
||||
results: list[MemoryRecord]
|
||||
|
||||
|
||||
SearchResponse: TypeAlias = list[MemoryRecord] | SearchResults
|
||||
|
||||
|
||||
class Mem0ContextProvider(ContextProvider):
|
||||
@@ -106,28 +111,85 @@ class Mem0ContextProvider(ContextProvider):
|
||||
if not input_text.strip():
|
||||
return
|
||||
|
||||
filters = self._build_filters()
|
||||
# Query entity partitions independently to bypass strict logical AND limitations
|
||||
# Mem0 OSS and Platform SDKs expose inconsistent search typings.
|
||||
search_tasks: list[Awaitable[Any]] = []
|
||||
|
||||
# AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs
|
||||
# AsyncMemoryClient (Platform) expects them in a filters dict
|
||||
search_kwargs: dict[str, Any] = {"query": input_text}
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
search_kwargs.update(filters)
|
||||
else:
|
||||
search_kwargs["filters"] = filters
|
||||
# 1. Query User partition independently
|
||||
if self.user_id:
|
||||
user_kwargs = self._build_search_kwargs(input_text, "user_id", self.user_id)
|
||||
search_tasks.append(self.mem0_client.search(**user_kwargs)) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
|
||||
search_response: _MemorySearchResponse_v1_1 | _MemorySearchResponse_v2 = await self.mem0_client.search( # type: ignore[misc]
|
||||
**search_kwargs,
|
||||
)
|
||||
# 2. Query Agent partition independently
|
||||
if self.agent_id:
|
||||
agent_kwargs = self._build_search_kwargs(input_text, "agent_id", self.agent_id)
|
||||
search_tasks.append(self.mem0_client.search(**agent_kwargs)) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
|
||||
if isinstance(search_response, list):
|
||||
memories = search_response
|
||||
elif isinstance(search_response, dict) and "results" in search_response:
|
||||
memories = search_response["results"]
|
||||
else:
|
||||
memories = [search_response]
|
||||
# Fall back to an app-scoped search when only application_id is configured
|
||||
if not search_tasks and self.application_id:
|
||||
app_kwargs: dict[str, Any] = {"query": input_text}
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
app_kwargs["app_id"] = self.application_id
|
||||
else:
|
||||
app_kwargs["filters"] = {"app_id": self.application_id}
|
||||
search_tasks.append(self.mem0_client.search(**app_kwargs)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
if not search_tasks:
|
||||
return
|
||||
|
||||
line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories)
|
||||
results: list[SearchResponse | BaseException] = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Merge and deduplicate results
|
||||
memories: list[MemoryRecord] = []
|
||||
seen_memory_ids: set[str] = set()
|
||||
failed_tasks_count: int = 0
|
||||
|
||||
for search_response in results:
|
||||
if isinstance(search_response, asyncio.CancelledError):
|
||||
raise search_response
|
||||
|
||||
if isinstance(search_response, BaseException):
|
||||
failed_tasks_count += 1
|
||||
logger.error(
|
||||
"Mem0 partition search task failed: %s",
|
||||
search_response,
|
||||
exc_info=(type(search_response), search_response, search_response.__traceback__),
|
||||
)
|
||||
continue
|
||||
|
||||
current_memories: list[MemoryRecord] = []
|
||||
if isinstance(search_response, list):
|
||||
current_memories = [mem for mem in search_response if isinstance(mem, dict)]
|
||||
elif isinstance(search_response, dict):
|
||||
results_field = search_response.get("results")
|
||||
if isinstance(results_field, list):
|
||||
current_memories = [
|
||||
item
|
||||
for item in results_field
|
||||
if isinstance(item, dict) # pyright: ignore[reportUnknownVariableType]
|
||||
]
|
||||
else:
|
||||
logger.warning(
|
||||
"Unexpected Mem0 search response format: %s",
|
||||
type(results_field).__name__,
|
||||
)
|
||||
|
||||
for mem in current_memories:
|
||||
mem_id = mem.get("id")
|
||||
if mem_id is not None and not isinstance(mem_id, str):
|
||||
mem_id = str(mem_id)
|
||||
|
||||
if mem_id is not None and mem_id in seen_memory_ids:
|
||||
continue
|
||||
|
||||
if mem_id is not None:
|
||||
seen_memory_ids.add(mem_id)
|
||||
|
||||
memories.append(mem)
|
||||
|
||||
if failed_tasks_count == len(search_tasks):
|
||||
logger.error("All Mem0 retrieval tasks failed. Context provider is unable to verify memory state.")
|
||||
|
||||
line_separated_memories = "\n".join(str(memory.get("memory", "")) for memory in memories)
|
||||
if line_separated_memories:
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
@@ -159,12 +221,21 @@ class Mem0ContextProvider(ContextProvider):
|
||||
]
|
||||
|
||||
if messages:
|
||||
await self.mem0_client.add( # type: ignore[misc]
|
||||
messages=messages,
|
||||
user_id=self.user_id,
|
||||
agent_id=self.agent_id,
|
||||
metadata={"application_id": self.application_id},
|
||||
)
|
||||
add_kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"user_id": self.user_id,
|
||||
"agent_id": self.agent_id,
|
||||
}
|
||||
|
||||
# Inject the application scope using the matching signature format for each SDK variant
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
if self.application_id:
|
||||
add_kwargs["app_id"] = self.application_id
|
||||
else:
|
||||
if self.application_id:
|
||||
add_kwargs["filters"] = {"app_id": self.application_id}
|
||||
|
||||
await self.mem0_client.add(**add_kwargs) # type: ignore[misc, call-arg]
|
||||
|
||||
# -- Internal methods ------------------------------------------------------
|
||||
|
||||
@@ -173,15 +244,21 @@ class Mem0ContextProvider(ContextProvider):
|
||||
if not self.agent_id and not self.user_id and not self.application_id:
|
||||
raise ValueError("At least one of the filters: agent_id, user_id, or application_id is required.")
|
||||
|
||||
def _build_filters(self) -> dict[str, Any]:
|
||||
"""Build search filters from initialization parameters."""
|
||||
filters: dict[str, Any] = {}
|
||||
if self.user_id:
|
||||
filters["user_id"] = self.user_id
|
||||
if self.agent_id:
|
||||
filters["agent_id"] = self.agent_id
|
||||
if self.application_id:
|
||||
filters["app_id"] = self.application_id
|
||||
def _build_search_kwargs(self, input_text: str, entity_key: str, entity_value: str) -> dict[str, Any]:
|
||||
"""Build search keyword arguments formatted for OSS vs Platform clients."""
|
||||
filters: dict[str, Any] = {"query": input_text}
|
||||
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
# AsyncMemory (OSS) expects direct kwargs
|
||||
filters[entity_key] = entity_value
|
||||
if self.application_id:
|
||||
filters["app_id"] = self.application_id
|
||||
else:
|
||||
# AsyncMemoryClient (Platform) expects a filters dict
|
||||
filters["filters"] = {entity_key: entity_value}
|
||||
if self.application_id:
|
||||
filters["filters"]["app_id"] = self.application_id
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
@@ -193,39 +193,59 @@ class TestBeforeRun:
|
||||
assert call_kwargs["user_id"] == "u1"
|
||||
assert "filters" not in call_kwargs
|
||||
|
||||
async def test_oss_client_all_scoping_params(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with all scoping parameters passes them as direct kwargs."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_client_all_scoping_params_except_app_id(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with all scoping parameters passes them as isolated concurrent kwargs."""
|
||||
mock_oss_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1", application_id="app1"
|
||||
source_id="mem0",
|
||||
mem0_client=mock_oss_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1"
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "hello"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
await provider.before_run(
|
||||
agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
call_kwargs = mock_oss_mem0_client.search.call_args.kwargs
|
||||
assert call_kwargs["user_id"] == "u1"
|
||||
assert call_kwargs["agent_id"] == "a1"
|
||||
assert "filters" not in call_kwargs
|
||||
# Re-aligned assertion: We expect 2 separate concurrent calls instead of 1 combined call
|
||||
assert mock_oss_mem0_client.search.call_count == 2
|
||||
mock_oss_mem0_client.search.assert_any_call(query="hello", user_id="u1")
|
||||
mock_oss_mem0_client.search.assert_any_call(query="hello", agent_id="a1")
|
||||
|
||||
async def test_platform_client_passes_filters_dict(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform AsyncMemoryClient should receive scoping params in a filters dict."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_client_passes_filters_dict_except_app_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform client passes scoping parameters concurrently inside the nested filters dictionary."""
|
||||
mock_mem0_client.search.return_value = []
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
)
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "hello"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
await provider.before_run(
|
||||
agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
call_kwargs = mock_mem0_client.search.call_args.kwargs
|
||||
assert call_kwargs["query"] == "Hello"
|
||||
assert "filters" in call_kwargs
|
||||
assert call_kwargs["filters"]["user_id"] == "u1"
|
||||
# Re-aligned assertion: Platform client isolates filters per call to bypass AND limitations
|
||||
assert mock_mem0_client.search.call_count == 2
|
||||
mock_mem0_client.search.assert_any_call(query="hello", filters={"user_id": "u1"})
|
||||
mock_mem0_client.search.assert_any_call(query="hello", filters={"agent_id": "a1"})
|
||||
|
||||
|
||||
# -- after_run tests -----------------------------------------------------------
|
||||
@@ -318,8 +338,8 @@ class TestAfterRun:
|
||||
with pytest.raises(ValueError, match="At least one of the filters"):
|
||||
await provider.after_run(agent=None, session=session, context=ctx, state=session.state) # type: ignore[arg-type]
|
||||
|
||||
async def test_stores_with_application_id_metadata(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""application_id is passed in metadata."""
|
||||
async def test_stores_with_application_id_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""application_id is passed in filters."""
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", application_id="app1"
|
||||
)
|
||||
@@ -331,7 +351,7 @@ class TestAfterRun:
|
||||
agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert mock_mem0_client.add.call_args.kwargs["metadata"] == {"application_id": "app1"}
|
||||
assert mock_mem0_client.add.call_args.kwargs["filters"] == {"app_id": "app1"}
|
||||
|
||||
|
||||
# -- _validate_filters tests --------------------------------------------------
|
||||
@@ -358,15 +378,20 @@ class TestValidateFilters:
|
||||
provider._validate_filters()
|
||||
|
||||
|
||||
# -- _build_filters tests -----------------------------------------------------
|
||||
# -- _build_search_kwargs tests -----------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildFilters:
|
||||
"""Test _build_filters method."""
|
||||
class TestBuildSearchKwargs:
|
||||
"""Test _build_search_kwargs method."""
|
||||
|
||||
def test_user_id_only(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
assert provider._build_filters() == {"user_id": "u1"}
|
||||
|
||||
# Pass the 3 required arguments
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
# AsyncMock triggers the Platform client nested 'filters' structure
|
||||
assert result == {"query": "test query", "filters": {"user_id": "u1"}}
|
||||
|
||||
def test_all_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(
|
||||
@@ -376,28 +401,66 @@ class TestBuildFilters:
|
||||
agent_id="a1",
|
||||
application_id="app1",
|
||||
)
|
||||
assert provider._build_filters() == {
|
||||
"user_id": "u1",
|
||||
"agent_id": "a1",
|
||||
"app_id": "app1",
|
||||
|
||||
# Test that app_id correctly merges with the isolated target entity
|
||||
result = provider._build_search_kwargs("test query", "agent_id", "a1")
|
||||
|
||||
assert result == {
|
||||
"query": "test query",
|
||||
"filters": {
|
||||
"agent_id": "a1",
|
||||
"app_id": "app1",
|
||||
},
|
||||
}
|
||||
|
||||
def test_excludes_none_values(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
filters = provider._build_filters()
|
||||
assert "agent_id" not in filters
|
||||
assert "run_id" not in filters
|
||||
assert "app_id" not in filters
|
||||
|
||||
# application_id is None by default, it should not appear in the dictionary
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
assert "app_id" not in result.get("filters", {})
|
||||
|
||||
def test_no_run_id_in_search_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""run_id is excluded from search filters so memories work across sessions."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
filters = provider._build_filters()
|
||||
assert "run_id" not in filters
|
||||
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
assert "run_id" not in result.get("filters", {})
|
||||
assert "run_id" not in result
|
||||
|
||||
def test_empty_when_no_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
# Validates base query payload generation
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
|
||||
assert provider._build_filters() == {}
|
||||
|
||||
result = provider._build_search_kwargs("test query", "custom_key", "custom_val")
|
||||
|
||||
assert result == {"query": "test query", "filters": {"custom_key": "custom_val"}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_run_application_only_fallback(self, mock_mem0_client: AsyncMock) -> None:
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, application_id="app_fallback_test"
|
||||
)
|
||||
|
||||
# Mock a valid message list and session container setup
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "Retrieve systemic fallback memory traces"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
mock_mem0_client.search = AsyncMock(return_value=[{"id": "m1", "memory": "System configuration template"}])
|
||||
|
||||
await provider.before_run(
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
# Verify that an application-scoped search task executed successfully
|
||||
assert mock_mem0_client.search.call_count == 1
|
||||
mock_context.extend_messages.assert_called_once()
|
||||
|
||||
|
||||
# -- Context manager tests -----------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mistral AI integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260505"
|
||||
version = "1.0.0a260604"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.1.0,<2",
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"mistralai>=2.0.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw OpenAI Chat client.
|
||||
|
||||
@@ -406,6 +407,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
env_file_path: Optional ``.env`` file that is checked before the process environment
|
||||
for ``OPENAI_*`` values.
|
||||
env_file_encoding: Encoding for the ``.env`` file.
|
||||
timeout: Optional timeout in seconds for requests.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -427,6 +429,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw OpenAI Chat client.
|
||||
|
||||
@@ -455,6 +458,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
env_file_path: Optional ``.env`` file that is checked before process environment
|
||||
variables for ``AZURE_OPENAI_*`` values.
|
||||
env_file_encoding: Encoding for the ``.env`` file.
|
||||
timeout: Optional timeout in seconds for requests.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -476,6 +480,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Initialize a raw OpenAI Chat client.
|
||||
|
||||
@@ -511,6 +516,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*``
|
||||
lookups.
|
||||
env_file_encoding: Encoding for the ``.env`` file.
|
||||
timeout: HTTP timeout in seconds for requests. When not provided, the
|
||||
OpenAI SDK default is used (connect: 5s, total: 600s).
|
||||
|
||||
Notes:
|
||||
Environment resolution and routing precedence are:
|
||||
@@ -541,6 +548,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
openai_model_fields=("chat_model", "model"),
|
||||
azure_model_fields=("chat_model", "model"),
|
||||
responses_mode=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
self.client = client
|
||||
@@ -1454,10 +1462,21 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
Returns:
|
||||
The prepared chat messages for a request.
|
||||
"""
|
||||
drops_reasoning_without_storage = not request_uses_service_side_storage and any(
|
||||
content.type == "text_reasoning" for message in chat_messages for content in message.contents
|
||||
)
|
||||
drop_mcp_call_ids: set[str] = set()
|
||||
if drops_reasoning_without_storage:
|
||||
for message in chat_messages:
|
||||
for content in message.contents:
|
||||
if content.type == "mcp_server_tool_call" and content.call_id:
|
||||
drop_mcp_call_ids.add(content.call_id)
|
||||
|
||||
list_of_list = [
|
||||
self._prepare_message_for_openai(
|
||||
message,
|
||||
request_uses_service_side_storage=request_uses_service_side_storage,
|
||||
drop_mcp_call_ids=drop_mcp_call_ids,
|
||||
)
|
||||
for message in chat_messages
|
||||
]
|
||||
@@ -1472,6 +1491,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
message: Message,
|
||||
*,
|
||||
request_uses_service_side_storage: bool = True,
|
||||
drop_mcp_call_ids: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Prepare a chat message for the OpenAI Responses API format."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
@@ -1491,7 +1511,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
# (replays_local_storage) still need stripping when the request also carries a continuation
|
||||
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
|
||||
# storage, standalone reasoning items are invalid per the API ("reasoning was provided
|
||||
# without its required following item"), so the reasoning branch always drops.
|
||||
# without its required following item"), so the reasoning branch always drops. When that
|
||||
# happens, `_prepare_messages_for_openai` also drops the paired hosted-MCP IDs across
|
||||
# message boundaries rather than replaying bare MCP items.
|
||||
drop_mcp_call_ids = drop_mcp_call_ids or set()
|
||||
for content in message.contents:
|
||||
match content.type:
|
||||
case "text_reasoning":
|
||||
@@ -1546,7 +1569,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
# server-side `id`, so under continuation it would duplicate
|
||||
# the prior response's items (#3295). Drop the call here; the
|
||||
# orphan result is dropped by the coalesce step that follows.
|
||||
if request_uses_service_side_storage:
|
||||
#
|
||||
# Without storage, a reasoning + hosted-MCP pair cannot be replayed
|
||||
# partially: reasoning is stripped above, and a bare mcp_call is rejected.
|
||||
if request_uses_service_side_storage or content.call_id in drop_mcp_call_ids:
|
||||
continue
|
||||
prepared_mcp = self._prepare_content_for_openai(
|
||||
message.role,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user