mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
745f9a7178 | ||
|
|
ea0b3c1210 | ||
|
|
92cd194122 | ||
|
|
55e665ade0 | ||
|
|
fed40ca1b2 | ||
|
|
e558d36ff6 | ||
|
|
6582926af5 | ||
|
|
0507179d3b | ||
|
|
b8e66a1144 | ||
|
|
bc42874690 | ||
|
|
dca9dc081b | ||
|
|
18293ffb31 | ||
|
|
c1cc6ee6df | ||
|
|
626b418622 | ||
|
|
3c91ba4050 | ||
|
|
540193ccef | ||
|
|
7999bf3c2d | ||
|
|
ccf22ac963 | ||
|
|
fb97e93a01 | ||
|
|
317ef4491e | ||
|
|
6cd81286a9 |
@@ -157,6 +157,8 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
OLLAMA_MODEL: qwen2.5:1.5b
|
||||
OLLAMA_EMBEDDING_MODEL: nomic-embed-text
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
@@ -171,6 +173,43 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Install Ollama
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
- name: Start Ollama and pull models
|
||||
run: |
|
||||
# Stop any Ollama instance auto-started by the install script
|
||||
pkill ollama || true
|
||||
sleep 2
|
||||
ollama serve &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Pull models with retry for transient 429 rate limits
|
||||
for model in qwen2.5:1.5b nomic-embed-text; do
|
||||
pulled=false
|
||||
for attempt in 1 2 3; do
|
||||
if ollama pull "$model"; then
|
||||
pulled=true
|
||||
break
|
||||
fi
|
||||
echo "Retry $attempt for $model (waiting 15s)..."
|
||||
sleep 15
|
||||
done
|
||||
if [ "$pulled" != "true" ]; then
|
||||
echo "ERROR: Failed to pull $model after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
working-directory: .
|
||||
- name: Start local MCP server
|
||||
id: local-mcp
|
||||
uses: ./.github/actions/setup-local-mcp-server
|
||||
@@ -271,7 +310,7 @@ jobs:
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--timeout=480 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
- name: Upload test results
|
||||
@@ -435,9 +474,9 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
@@ -471,36 +510,36 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore flaky report history cache
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
flaky-report-history-integration-
|
||||
integration-report-history-integration-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
uv run python scripts/integration_test_report/aggregate.py
|
||||
../test-results/
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
integration-report-history.json
|
||||
integration-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-integration-${{ github.run_id }}
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flaky-test-report
|
||||
name: integration-test-report
|
||||
path: |
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
python/integration-test-report.md
|
||||
python/integration-report-history.json
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
|
||||
@@ -278,6 +278,8 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
|
||||
OLLAMA_MODEL: qwen2.5:1.5b
|
||||
OLLAMA_EMBEDDING_MODEL: nomic-embed-text
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
@@ -289,6 +291,43 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Install Ollama
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
- name: Start Ollama and pull models
|
||||
run: |
|
||||
# Stop any Ollama instance auto-started by the install script
|
||||
pkill ollama || true
|
||||
sleep 2
|
||||
ollama serve &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Pull models with retry for transient 429 rate limits
|
||||
for model in qwen2.5:1.5b nomic-embed-text; do
|
||||
pulled=false
|
||||
for attempt in 1 2 3; do
|
||||
if ollama pull "$model"; then
|
||||
pulled=true
|
||||
break
|
||||
fi
|
||||
echo "Retry $attempt for $model (waiting 15s)..."
|
||||
sleep 15
|
||||
done
|
||||
if [ "$pulled" != "true" ]; then
|
||||
echo "ERROR: Failed to pull $model after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
working-directory: .
|
||||
- name: Start local MCP server
|
||||
id: local-mcp
|
||||
uses: ./.github/actions/setup-local-mcp-server
|
||||
@@ -403,7 +442,7 @@ jobs:
|
||||
-m integration
|
||||
-n logical --dist worksteal
|
||||
-x
|
||||
--timeout=360 --session-timeout=900 --timeout_method thread
|
||||
--timeout=480 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
--junitxml=pytest.xml
|
||||
working-directory: ./python
|
||||
@@ -619,9 +658,9 @@ jobs:
|
||||
path: ./python/pytest.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Flaky test trend report (aggregates per-job JUnit XML results)
|
||||
python-flaky-test-report:
|
||||
name: Flaky Test Report
|
||||
# Integration test trend report (aggregates per-job JUnit XML results)
|
||||
python-integration-test-report:
|
||||
name: Integration Test Report
|
||||
if: >
|
||||
always() &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
@@ -652,36 +691,36 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore flaky report history cache
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
flaky-report-history-merge-
|
||||
integration-report-history-merge-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/flaky_report/aggregate.py
|
||||
uv run python scripts/integration_test_report/aggregate.py
|
||||
../test-results/
|
||||
flaky-report-history.json
|
||||
flaky-test-report.md
|
||||
integration-report-history.json
|
||||
integration-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save flaky report history cache
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/flaky-report-history.json
|
||||
key: flaky-report-history-merge-${{ github.run_id }}
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
- name: Upload unified trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flaky-test-report
|
||||
name: integration-test-report
|
||||
path: |
|
||||
python/flaky-test-report.md
|
||||
python/flaky-report-history.json
|
||||
python/integration-test-report.md
|
||||
python/integration-report-history.json
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
|
||||
@@ -599,6 +599,7 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
|
||||
|
||||
@@ -297,6 +297,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
|
||||
}
|
||||
|
||||
@@ -310,12 +311,13 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(defaultAgent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
@@ -352,7 +354,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
|
||||
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton<AgentSessionStore>(\"{agentName}\", ...).";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The supplement value is computed once from the Microsoft.Agents.AI.Foundry.Hosting
|
||||
/// assembly's informational version. The policy is idempotent on retries: if the segment
|
||||
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This policy is added at request time (per-call <see cref="PipelinePosition"/>)
|
||||
/// by <see cref="UserAgentResponsesClient"/> when invoking the wrapped
|
||||
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
|
||||
/// resolved by the Foundry hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static HostedAgentUserAgentPolicy Instance { get; } = new HostedAgentUserAgentPolicy();
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_supplementValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSupplementValue()
|
||||
{
|
||||
const string Name = "foundry-hosting/agent-framework-dotnet";
|
||||
|
||||
if (typeof(HostedAgentUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -36,7 +35,7 @@ public static class FoundryHostingExtensions
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent);
|
||||
/// builder.Services.AddFoundryResponses();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
@@ -181,13 +180,6 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
|
||||
if (endpoints is IApplicationBuilder app)
|
||||
{
|
||||
// Ensure the middleware is added to the pipeline
|
||||
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -216,46 +208,85 @@ public static class FoundryHostingExtensions
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
|
||||
/// <summary>
|
||||
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
|
||||
/// with a <see cref="UserAgentResponsesClient"/> so every outgoing Responses-API request
|
||||
/// carries the hosted-agent <c>User-Agent</c> segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Best-effort and idempotent. The method is a no-op when:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
|
||||
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
|
||||
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="UserAgentResponsesClient"/>.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Works for any <see cref="ResponsesClient"/>-derived inner client — both the Foundry-specific
|
||||
/// <see cref="Azure.AI.Extensions.OpenAI.ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/> obtained from <see cref="OpenAI.OpenAIClient"/>. The wrapper preserves
|
||||
/// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
|
||||
/// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns the same <paramref name="agent"/> instance unchanged. Mutation happens via
|
||||
/// reflection on MEAI's private <c>_responseClient</c> field; the agent itself is not wrapped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static AIAgent TryApplyUserAgent(AIAgent agent)
|
||||
{
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
if (chatClient is null)
|
||||
{
|
||||
var headers = context.Request.Headers;
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
headers.UserAgent = s_userAgentValue;
|
||||
}
|
||||
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
var meaiType = s_meaiResponsesChatClientType;
|
||||
if (meaiType is null)
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
return agent;
|
||||
}
|
||||
|
||||
var meaiInstance = chatClient.GetService(meaiType);
|
||||
if (meaiInstance is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var field = s_meaiResponseClientField;
|
||||
if (field is null)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
var current = field.GetValue(meaiInstance) as ResponsesClient;
|
||||
if (current is null or UserAgentResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
|
||||
return agent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>OpenAIResponsesChatClient</c> type, resolved once via reflection.
|
||||
/// <see langword="null"/> if the type cannot be found (e.g., MEAI version drift).
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode",
|
||||
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
|
||||
private static readonly Type? s_meaiResponsesChatClientType =
|
||||
typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
|
||||
/// <summary>
|
||||
/// MEAI's internal <c>_responseClient</c> field on <c>OpenAIResponsesChatClient</c>,
|
||||
/// resolved once via reflection. <see langword="null"/> if the field cannot be found.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers",
|
||||
Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")]
|
||||
private static readonly FieldInfo? s_meaiResponseClientField =
|
||||
s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ResponsesClient"/> subclass that delegates every protocol-level request to a
|
||||
/// wrapped <see cref="ResponsesClient"/>. Before each call, a
|
||||
/// <see cref="HostedAgentUserAgentPolicy"/> is added to the per-call
|
||||
/// <see cref="RequestOptions"/> so the wrapped client's pipeline appends the hosted-agent
|
||||
/// <c>User-Agent</c> segment on the wire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The streaming overloads MEAI binds via reflection (<c>internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)</c>
|
||||
/// and <c>internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)</c>) bottom out
|
||||
/// in calls to the public-virtual non-streaming protocol overloads on <see langword="this"/>. Overriding those
|
||||
/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The base pipeline supplied to <see cref="ResponsesClient(ClientPipeline, OpenAIClientOptions)"/>
|
||||
/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class
|
||||
/// delegates to the inner client BEFORE any code path reaches <see cref="ResponsesClient.Pipeline"/>, so the dummy is
|
||||
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class UserAgentResponsesClient : ResponsesClient
|
||||
{
|
||||
private readonly ResponsesClient _inner;
|
||||
|
||||
public UserAgentResponsesClient(ResponsesClient inner)
|
||||
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
|
||||
{
|
||||
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public override async Task<ClientResult> CreateResponseAsync(BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CreateResponse(content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseAsync(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponse(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
|
||||
=> this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> DeleteResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult DeleteResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CancelResponseAsync(string responseId, RequestOptions options)
|
||||
=> await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CancelResponse(string responseId, RequestOptions options)
|
||||
=> this._inner.CancelResponse(responseId, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null)
|
||||
=> this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options));
|
||||
|
||||
public override async Task<ClientResult> GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false);
|
||||
|
||||
public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options)
|
||||
=> this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options));
|
||||
|
||||
private static RequestOptions AddUserAgentPolicy(RequestOptions? options)
|
||||
{
|
||||
options ??= new RequestOptions();
|
||||
options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static ClientPipeline BuildDummyPipeline()
|
||||
{
|
||||
var options = new ClientPipelineOptions
|
||||
{
|
||||
Transport = new ThrowingTransport(),
|
||||
};
|
||||
return ClientPipeline.Create(options, default, default, default);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTransport : PipelineTransport
|
||||
{
|
||||
private const string Message =
|
||||
"UserAgentResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of UserAgentResponsesClient.";
|
||||
|
||||
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
|
||||
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.Hosting.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -13,20 +12,6 @@ internal static class RequestOptionsExtensions
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Creates a <see cref="RequestOptions"/> configured for use with Foundry Agents.</summary>
|
||||
public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming)
|
||||
{
|
||||
RequestOptions requestOptions = new()
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
BufferResponse = !streaming
|
||||
};
|
||||
|
||||
requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
|
||||
+1
-2
@@ -10,7 +10,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -19,7 +18,7 @@ using OpenTelemetry;
|
||||
using OpenTelemetry.Trace;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that verify OTel spans are actually emitted and captured through the
|
||||
+1
-2
@@ -9,7 +9,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,7 +16,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class AgentFrameworkResponseHandlerTests
|
||||
{
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentFrameworkResponseHandler"/> that verify behavior
|
||||
/// when the registered agent is a workflow-backed <see cref="AIAgent"/>. These exercise
|
||||
/// real workflow builders and the in-process execution environment to drive the handler
|
||||
/// through realistic streaming event patterns.
|
||||
/// </summary>
|
||||
public class AgentFrameworkResponseHandlerWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
|
||||
{
|
||||
// Arrange: single-agent sequential workflow
|
||||
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "workflow-agent",
|
||||
name: "Test Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + at least one text output + terminal
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
|
||||
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
|
||||
{
|
||||
// Arrange: two agents in sequence
|
||||
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
|
||||
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "seq-workflow",
|
||||
name: "Sequential Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have workflow action events for executor lifecycle
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
|
||||
// Should have output item events (either text messages or workflow actions)
|
||||
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
|
||||
"Expected at least one output item from the workflow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
|
||||
{
|
||||
// Arrange: workflow with an agent that throws
|
||||
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "error-workflow",
|
||||
name: "Error Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + error/failure indicator
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
|
||||
var lastEvent = events[^1];
|
||||
// Workflow errors surface as either Failed or Completed (depending on error handling)
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new StreamingTextAgent("test-agent", "Result");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "actions-workflow",
|
||||
name: "Actions Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
|
||||
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
|
||||
Assert.True(addedEvents.Count >= 1,
|
||||
$"Expected at least 1 output item added event, got {addedEvents.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
|
||||
{
|
||||
// Arrange: workflow agent registered with a keyed service name
|
||||
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "keyed-workflow",
|
||||
name: "Keyed Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton("my-workflow", workflowAgent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, mockContext.Object);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
|
||||
}
|
||||
|
||||
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
|
||||
CreateHandlerWithAgent(AIAgent agent, string userMessage)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
return (handler, request, mockContext.Object);
|
||||
}
|
||||
|
||||
private static BinaryData CreateUserInput(string text)
|
||||
{
|
||||
return BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<ResponseContext> CreateMockContext()
|
||||
{
|
||||
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
|
||||
AgentFrameworkResponseHandler handler,
|
||||
CreateResponse request,
|
||||
ResponseContext context)
|
||||
{
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
internal sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider
|
||||
{
|
||||
public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary<string, object> properties)
|
||||
{
|
||||
return new GetTokenOptions(new Dictionary<string, object>());
|
||||
}
|
||||
|
||||
public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
public override ValueTask<AuthenticationToken> GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask<AuthenticationToken>(this.GetToken(options, cancellationToken));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryAIToolExtensionsTests
|
||||
{
|
||||
+1
-2
@@ -6,10 +6,9 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryToolboxBearerTokenHandlerTests
|
||||
{
|
||||
+1
-2
@@ -4,11 +4,10 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
+1
-2
@@ -9,13 +9,12 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FoundryToolbox"/> class.
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline:
|
||||
/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent →
|
||||
/// agent invocation → outbound HTTP from inside the hosted environment.
|
||||
/// Verifies that the hosted-agent <c>User-Agent</c> supplement reaches the outbound wire,
|
||||
/// not just the inbound request.
|
||||
/// </summary>
|
||||
public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string Deployment = "fake-deployment";
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _inboundClient;
|
||||
private RecordingHandler? _outboundHandler;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._inboundClient?.Dispose();
|
||||
this._outboundHandler?.Dispose();
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync()
|
||||
{
|
||||
// Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's
|
||||
// OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the
|
||||
// exact production stack minus the network: the only thing not real is the wire transport.
|
||||
await this.StartHostedServerAsync();
|
||||
|
||||
// Act: send an inbound /openai/v1/responses request as the Foundry runtime would.
|
||||
using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses")
|
||||
{
|
||||
Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest);
|
||||
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
|
||||
// foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent.
|
||||
// (We don't care about the inbound response shape — only that the agent's call to MEAI
|
||||
// triggered an outbound request whose UA reaches the sandbox boundary correctly.)
|
||||
Assert.True(this._outboundHandler!.Requests.Count > 0,
|
||||
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
|
||||
var outbound = this._outboundHandler.Requests[0];
|
||||
Assert.StartsWith(TestEndpoint, outbound.Uri);
|
||||
Assert.Contains("MEAI/", outbound.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent);
|
||||
}
|
||||
|
||||
private async Task StartHostedServerAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
// Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient
|
||||
// wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler
|
||||
// resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper.
|
||||
this._outboundHandler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
var outboundHttpClient = new HttpClient(this._outboundHandler);
|
||||
#pragma warning restore CA5399
|
||||
|
||||
var projectOptions = new ProjectResponsesClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(outboundHttpClient),
|
||||
};
|
||||
var projectResponsesClient = new ProjectResponsesClient(
|
||||
new Uri(TestEndpoint),
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
projectOptions);
|
||||
|
||||
IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment);
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddLogging();
|
||||
|
||||
this._app = builder.Build();
|
||||
this._app.MapFoundryResponses();
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._inboundClient = testServer.CreateClient();
|
||||
}
|
||||
|
||||
private static string InboundResponsesRequestJson() => """
|
||||
{
|
||||
"model": "fake-deployment",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "Hello" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RecordingHandler(string body)
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string ua = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: "(none)";
|
||||
this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", ua));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RecordedRequest(string Uri, string UserAgent);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
internal sealed class HttpHandlerAssert : HttpClientHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage>? _assertion;
|
||||
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>>? _assertionAsync;
|
||||
|
||||
public HttpHandlerAssert(Func<HttpRequestMessage, HttpResponseMessage> assertion)
|
||||
{
|
||||
this._assertion = assertion;
|
||||
}
|
||||
public HttpHandlerAssert(Func<HttpRequestMessage, Task<HttpResponseMessage>> assertionAsync)
|
||||
{
|
||||
this._assertionAsync = assertionAsync;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._assertionAsync is not null)
|
||||
{
|
||||
return await this._assertionAsync.Invoke(request);
|
||||
}
|
||||
|
||||
return this._assertion!.Invoke(request);
|
||||
}
|
||||
|
||||
#if NET
|
||||
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return this._assertion!(request);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+1
-2
@@ -3,11 +3,10 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class InputConverterTests
|
||||
{
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<NoWarn>$(NoWarn);NU1605;NU1903</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
-2
@@ -7,13 +7,12 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class OutputConverterTests
|
||||
{
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OutputConverter"/> driven directly by hand-crafted update
|
||||
/// sequences that mirror the patterns produced by real workflow executions
|
||||
/// (sequential, group chat, code executor, sub-workflow, mixed content types).
|
||||
/// </summary>
|
||||
public class OutputConverterWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SequentialWorkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
// Superstep 1: Agent 1
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Superstep 2: Agent 2
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow action items + 2 text messages = 6 output items
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupChatPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 6 workflow actions + 3 text messages = 9 output items
|
||||
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CodeExecutorPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
|
||||
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Second executor uses the output
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow actions + 1 text message = 5 output items
|
||||
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubworkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a parent workflow that invokes a sub-workflow executor
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
// Sub-workflow executor invoked
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
|
||||
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
|
||||
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
|
||||
// Sub-workflow executor completed
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 2 workflow actions + 1 text message = 3 output items
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
|
||||
{
|
||||
// Simulate a workflow producing reasoning, text, function calls, and usage
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
|
||||
// Reasoning
|
||||
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
|
||||
// Function call (tool use)
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new FunctionCallContent("call_search", "web_search",
|
||||
new Dictionary<string, object?> { ["query"] = "latest news" })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
|
||||
// Next executor uses tool result
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Workflow actions: 4 (2 invoked + 2 completed)
|
||||
// Content: 1 reasoning + 1 function call + 1 text message = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+44
-2
@@ -3,11 +3,12 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
@@ -93,4 +94,45 @@ public class ServiceCollectionExtensionsTests
|
||||
|
||||
Assert.Same(instrumented, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithoutChatClient_NoOp()
|
||||
{
|
||||
// Arrange: agent.GetService<IChatClient>() returns null.
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp()
|
||||
{
|
||||
// Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService.
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
mockChatClient.Setup(c => c.GetService(It.IsAny<Type>(), It.IsAny<object?>())).Returns(null!);
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny<object?>())).Returns(mockChatClient.Object);
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target type-name.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.IO;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for loading toolbox-related test data files.
|
||||
/// </summary>
|
||||
internal static class TestDataUtil
|
||||
{
|
||||
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
|
||||
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
|
||||
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox record response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON with decoration fields on tools.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="UserAgentResponsesClient"/> preserves user-supplied client options
|
||||
/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
|
||||
/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
|
||||
/// Covers both the Azure-flavored <see cref="ProjectResponsesClient"/> and the native OpenAI
|
||||
/// <see cref="ResponsesClient"/>.
|
||||
/// </summary>
|
||||
public sealed partial class UserAgentResponsesClientTests
|
||||
{
|
||||
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
||||
private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
|
||||
private const string Deployment = "fake-deployment";
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")]
|
||||
private static partial System.Text.RegularExpressions.Regex SupplementRegex();
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(TestEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalSseResponse());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(TestEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient,
|
||||
userAgentApplicationId: "MY_APP_ID",
|
||||
organizationId: "org_xyz",
|
||||
projectId: "proj_abc");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert: retry policy ran (1 + 2 extras = 3 attempts).
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
Assert.Equal(3, retryPolicy.InvocationCount);
|
||||
foreach (var req in handler.Requests)
|
||||
{
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = inner.AsIChatClient(Deployment);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved).
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(OpenAIEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler(MinimalSseResponse());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("MEAI/", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
Assert.StartsWith(OpenAIEndpoint, req.Uri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DeleteResponseAsync")]
|
||||
[InlineData("CancelResponseAsync")]
|
||||
[InlineData("GetInputTokenCountAsync")]
|
||||
[InlineData("CompactResponseAsync")]
|
||||
[InlineData("GetResponseInputItemCollectionPageAsync")]
|
||||
public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method)
|
||||
{
|
||||
// Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that
|
||||
// grabs the underlying ResponsesClient via chat.GetService<ResponsesClient>() and invokes
|
||||
// a non-Create/Get protocol method. This is the regression path: without overriding these,
|
||||
// the wrapper's dummy throwing pipeline would fire.
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
var wrapper = new UserAgentResponsesClient(inner);
|
||||
|
||||
// Act
|
||||
switch (method)
|
||||
{
|
||||
case "DeleteResponseAsync":
|
||||
_ = await wrapper.DeleteResponseAsync("resp_1", options: null!);
|
||||
break;
|
||||
case "CancelResponseAsync":
|
||||
_ = await wrapper.CancelResponseAsync("resp_1", options: null!);
|
||||
break;
|
||||
case "GetInputTokenCountAsync":
|
||||
_ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
break;
|
||||
case "CompactResponseAsync":
|
||||
_ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
|
||||
break;
|
||||
case "GetResponseInputItemCollectionPageAsync":
|
||||
_ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!);
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"Unhandled method: {method}");
|
||||
break;
|
||||
}
|
||||
|
||||
// Assert
|
||||
var req = Assert.Single(handler.Requests);
|
||||
Assert.Contains("MY_APP_ID", req.UserAgent);
|
||||
Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync()
|
||||
{
|
||||
// Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message,
|
||||
// so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers.
|
||||
// The policy's Contains-guard must prevent the supplement from appearing twice.
|
||||
var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
|
||||
var chat = MakeWithDelegating(inner);
|
||||
|
||||
// Act
|
||||
_ = await chat.GetResponseAsync("hello");
|
||||
|
||||
// Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two.
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
foreach (var req in handler.Requests)
|
||||
{
|
||||
int matches = SupplementRegex().Matches(req.UserAgent).Count;
|
||||
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync()
|
||||
{
|
||||
// Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's
|
||||
// OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport).
|
||||
using var handler = new RecordingHandler(MinimalResponseJson());
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
|
||||
IChatClient chatClient = inner.AsIChatClient(Deployment);
|
||||
AIAgent agent = new ChatClientAgent(chatClient);
|
||||
|
||||
// Act: apply twice.
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
|
||||
// Assert: invoking the agent produces exactly ONE outbound request whose UA contains
|
||||
// the supplement EXACTLY ONCE (would be twice if the wrapper were nested).
|
||||
_ = await chatClient.GetResponseAsync("hello");
|
||||
var req = Assert.Single(handler.Requests);
|
||||
int matches = SupplementRegex().Matches(req.UserAgent).Count;
|
||||
Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target. Failure here means MEAI internals
|
||||
// changed and the polyfill needs updating.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
|
||||
var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(field);
|
||||
Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType),
|
||||
$"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponsesClient_PipelineProperty_ReflectionGuard()
|
||||
{
|
||||
// The polyfill design assumes ResponsesClient.Pipeline remains accessible.
|
||||
var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.NotNull(pipelineProp);
|
||||
Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType);
|
||||
}
|
||||
|
||||
private static IChatClient MakeWithDelegating(ResponsesClient inner)
|
||||
{
|
||||
IChatClient meai = inner.AsIChatClient(Deployment);
|
||||
var meaiType = meai.GetType();
|
||||
var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
field.SetValue(meai, new UserAgentResponsesClient(inner));
|
||||
return meai;
|
||||
}
|
||||
|
||||
private static ProjectResponsesClient BuildInner(
|
||||
HttpClient httpClient,
|
||||
string? userAgentApplicationId = null,
|
||||
string? organizationId = null,
|
||||
string? projectId = null,
|
||||
PipelinePolicy? retryPolicy = null)
|
||||
{
|
||||
var options = new ProjectResponsesClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
};
|
||||
if (userAgentApplicationId is not null)
|
||||
{
|
||||
options.UserAgentApplicationId = userAgentApplicationId;
|
||||
}
|
||||
if (organizationId is not null)
|
||||
{
|
||||
options.OrganizationId = organizationId;
|
||||
}
|
||||
if (projectId is not null)
|
||||
{
|
||||
options.ProjectId = projectId;
|
||||
}
|
||||
if (retryPolicy is not null)
|
||||
{
|
||||
options.RetryPolicy = retryPolicy;
|
||||
}
|
||||
|
||||
return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options);
|
||||
}
|
||||
|
||||
private static ResponsesClient BuildOpenAIInner(
|
||||
HttpClient httpClient,
|
||||
string? userAgentApplicationId = null)
|
||||
{
|
||||
var options = new OpenAIClientOptions
|
||||
{
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
Endpoint = new Uri(OpenAIEndpoint),
|
||||
};
|
||||
if (userAgentApplicationId is not null)
|
||||
{
|
||||
options.UserAgentApplicationId = userAgentApplicationId;
|
||||
}
|
||||
|
||||
return new ResponsesClient(new ApiKeyCredential("test-key"), options);
|
||||
}
|
||||
|
||||
private static string MinimalResponseJson() => """
|
||||
{
|
||||
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
|
||||
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}
|
||||
""";
|
||||
|
||||
private static string MinimalSseResponse()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("event: response.completed\n");
|
||||
sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n");
|
||||
sb.Append("data: [DONE]\n\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
private readonly string _body;
|
||||
public List<RecordedRequest> Requests { get; } = [];
|
||||
|
||||
public RecordingHandler(string body)
|
||||
{
|
||||
this._body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string ua = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: "(none)";
|
||||
this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua));
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent);
|
||||
|
||||
private sealed class CountingRetryPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly int _extraAttempts;
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public CountingRetryPolicy(int extraAttempts)
|
||||
{
|
||||
this._extraAttempts = extraAttempts;
|
||||
}
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
for (int i = 0; i <= this._extraAttempts; i++)
|
||||
{
|
||||
this.InvocationCount++;
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
for (int i = 0; i <= this._extraAttempts; i++)
|
||||
{
|
||||
this.InvocationCount++;
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that streams a single text update.
|
||||
/// </summary>
|
||||
internal sealed class StreamingTextAgent(string id, string responseText) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = $"msg_{id}",
|
||||
Contents = [new MeaiTextContent(responseText)]
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that always throws an exception during streaming.
|
||||
/// </summary>
|
||||
internal sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw exception;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>AgentFrameworkUserAgentMiddleware</c> registered by
|
||||
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>.
|
||||
/// </summary>
|
||||
public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable
|
||||
{
|
||||
private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?";
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._httpClient?.Dispose();
|
||||
if (this._app != null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("MyApp/1.0", userAgent);
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
// First request to capture the actual middleware-generated value
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
var firstResponse = await this._httpClient!.SendAsync(firstRequest);
|
||||
var middlewareValue = await firstResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Act: send a second request that already contains the middleware value
|
||||
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}");
|
||||
var secondResponse = await this._httpClient!.SendAsync(secondRequest);
|
||||
var userAgent = await secondResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: should remain unchanged (no duplication)
|
||||
Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent);
|
||||
Assert.Single(VersionedUserAgentRegex().Matches(userAgent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: should match "agent-framework-dotnet/x.y.z" pattern
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
private async Task CreateTestServerAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
this._app = builder.Build();
|
||||
this._app.MapFoundryResponses();
|
||||
|
||||
// Test endpoint that echoes the User-Agent header after middleware processing
|
||||
this._app.MapGet("/test-ua", (HttpContext ctx) =>
|
||||
Results.Text(ctx.Request.Headers.UserAgent.ToString()));
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._httpClient = testServer.CreateClient();
|
||||
}
|
||||
|
||||
[GeneratedRegex(VersionedUserAgentPattern)]
|
||||
private static partial Regex VersionedUserAgentRegex();
|
||||
}
|
||||
-508
@@ -1,508 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify workflow execution through the
|
||||
/// <see cref="AgentFrameworkResponseHandler"/> → <see cref="OutputConverter"/> pipeline.
|
||||
/// These use real workflow builders and the InProcessExecution environment
|
||||
/// to produce authentic streaming event patterns.
|
||||
/// </summary>
|
||||
public class WorkflowIntegrationTests
|
||||
{
|
||||
// ===== Sequential Workflow Tests =====
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync()
|
||||
{
|
||||
// Arrange: single-agent sequential workflow
|
||||
var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "workflow-agent",
|
||||
name: "Test Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + at least one text output + terminal
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}");
|
||||
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync()
|
||||
{
|
||||
// Arrange: two agents in sequence
|
||||
var agent1 = new StreamingTextAgent("agent1", "First agent says hello");
|
||||
var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "seq-workflow",
|
||||
name: "Sequential Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have workflow action events for executor lifecycle
|
||||
var lastEvent = events[^1];
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
|
||||
// Should have output item events (either text messages or workflow actions)
|
||||
Assert.True(events.OfType<ResponseOutputItemAddedEvent>().Any(),
|
||||
"Expected at least one output item from the workflow");
|
||||
}
|
||||
|
||||
// ===== Workflow Error Propagation =====
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync()
|
||||
{
|
||||
// Arrange: workflow with an agent that throws
|
||||
var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed"));
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "error-workflow",
|
||||
name: "Error Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread,
|
||||
includeExceptionDetails: true);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: should have lifecycle events + error/failure indicator
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.IsType<ResponseInProgressEvent>(events[1]);
|
||||
|
||||
var lastEvent = events[^1];
|
||||
// Workflow errors surface as either Failed or Completed (depending on error handling)
|
||||
Assert.True(
|
||||
lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent,
|
||||
$"Expected terminal event, got {lastEvent.GetType().Name}");
|
||||
}
|
||||
|
||||
// ===== Workflow Action Lifecycle Events =====
|
||||
|
||||
[Fact]
|
||||
public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = new StreamingTextAgent("test-agent", "Result");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "actions-workflow",
|
||||
name: "Actions Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello");
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, context);
|
||||
|
||||
// Assert: workflow should produce OutputItemAdded events for executor lifecycle
|
||||
var addedEvents = events.OfType<ResponseOutputItemAddedEvent>().ToList();
|
||||
Assert.True(addedEvents.Count >= 1,
|
||||
$"Expected at least 1 output item added event, got {addedEvents.Count}");
|
||||
}
|
||||
|
||||
// ===== Keyed Workflow Registration =====
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync()
|
||||
{
|
||||
// Arrange: workflow agent registered with a keyed service name
|
||||
var agent = new StreamingTextAgent("inner", "Keyed workflow response");
|
||||
var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent);
|
||||
var workflowAgent = workflow.AsAIAgent(
|
||||
id: "keyed-workflow",
|
||||
name: "Keyed Workflow",
|
||||
executionEnvironment: InProcessExecution.OffThread);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddKeyedSingleton("my-workflow", workflowAgent);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") };
|
||||
request.Input = CreateUserInput("Test keyed workflow");
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, request, mockContext.Object);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<ResponseCreatedEvent>(events[0]);
|
||||
Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}");
|
||||
}
|
||||
|
||||
// ===== OutputConverter Direct Workflow Pattern Tests =====
|
||||
// These test the OutputConverter directly with update patterns that mirror real workflows.
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate what WorkflowSession produces for a 2-agent sequential workflow
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
// Superstep 1: Agent 1
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Superstep 2: Agent 2
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow action items + 2 text messages = 6 output items
|
||||
Assert.Equal(6, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_GroupChatPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") },
|
||||
new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 6 workflow actions + 3 text messages = 9 output items
|
||||
Assert.Equal(9, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Equal(3, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a code-based FunctionExecutor: invoked → completed, no text content
|
||||
// (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle)
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
// Second executor uses the output
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] },
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 4 workflow actions + 1 text message = 5 output items
|
||||
Assert.Equal(5, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEventsAsync()
|
||||
{
|
||||
// Simulate a parent workflow that invokes a sub-workflow executor
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) },
|
||||
// Sub-workflow executor invoked
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") },
|
||||
// Inner agent within sub-workflow produces text (unwrapped by WorkflowSession)
|
||||
new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] },
|
||||
// Sub-workflow executor completed
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) },
|
||||
new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// 2 workflow actions + 1 text message = 3 output items
|
||||
Assert.Equal(3, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Single(events.OfType<ResponseTextDeltaEvent>());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync()
|
||||
{
|
||||
// Simulate a workflow producing reasoning, text, function calls, and usage
|
||||
var (stream, _) = CreateTestStream();
|
||||
var updates = new[]
|
||||
{
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") },
|
||||
// Reasoning
|
||||
new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] },
|
||||
// Function call (tool use)
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new FunctionCallContent("call_search", "web_search",
|
||||
new Dictionary<string, object?> { ["query"] = "latest news" })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) },
|
||||
// Next executor uses tool result
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] },
|
||||
new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] },
|
||||
new AgentResponseUpdate
|
||||
{
|
||||
Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })]
|
||||
},
|
||||
new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) },
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Workflow actions: 4 (2 invoked + 2 completed)
|
||||
// Content: 1 reasoning + 1 function call + 1 text message = 3
|
||||
// Total: 7 output items
|
||||
Assert.Equal(7, events.OfType<ResponseOutputItemAddedEvent>().Count());
|
||||
Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent);
|
||||
Assert.Equal(2, events.OfType<ResponseTextDeltaEvent>().Count());
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context)
|
||||
CreateHandlerWithAgent(AIAgent agent, string userMessage)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<AgentSessionStore>(new InMemoryAgentSessionStore());
|
||||
services.AddSingleton(agent);
|
||||
services.AddSingleton<ILogger<AgentFrameworkResponseHandler>>(NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var handler = new AgentFrameworkResponseHandler(sp, NullLogger<AgentFrameworkResponseHandler>.Instance);
|
||||
var request = new CreateResponse { Model = "test" };
|
||||
request.Input = CreateUserInput(userMessage);
|
||||
var mockContext = CreateMockContext();
|
||||
|
||||
return (handler, request, mockContext.Object);
|
||||
}
|
||||
|
||||
private static BinaryData CreateUserInput(string text)
|
||||
{
|
||||
return BinaryData.FromObjectAsJson(new[]
|
||||
{
|
||||
new { type = "message", id = "msg_in_1", status = "completed", role = "user",
|
||||
content = new[] { new { type = "input_text", text } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<ResponseContext> CreateMockContext()
|
||||
{
|
||||
var mock = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
mock.Setup(x => x.GetHistoryAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<OutputItem>());
|
||||
mock.Setup(x => x.GetInputItemsAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Item>());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static (ResponseEventStream stream, Mock<ResponseContext> mockContext) CreateTestStream()
|
||||
{
|
||||
var mockContext = new Mock<ResponseContext>("resp_" + new string('0', 46)) { CallBase = true };
|
||||
var request = new CreateResponse { Model = "test-model" };
|
||||
var stream = new ResponseEventStream(mockContext.Object, request);
|
||||
return (stream, mockContext);
|
||||
}
|
||||
|
||||
private static async Task<List<ResponseStreamEvent>> CollectEventsAsync(
|
||||
AgentFrameworkResponseHandler handler,
|
||||
CreateResponse request,
|
||||
ResponseContext context)
|
||||
{
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ===== Test Agent Types =====
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that streams a single text update.
|
||||
/// </summary>
|
||||
private sealed class StreamingTextAgent(string id, string responseText) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new AgentResponseUpdate
|
||||
{
|
||||
MessageId = $"msg_{id}",
|
||||
Contents = [new MeaiTextContent(responseText)]
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A test agent that always throws an exception during streaming.
|
||||
/// </summary>
|
||||
private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent
|
||||
{
|
||||
public new string Id => id;
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw exception;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session,
|
||||
AgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
-29
@@ -7,33 +7,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Hosting tests only compile on .NET Core TFMs -->
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<Compile Remove="Hosting\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp'">
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<Compile Remove="FoundryEvalConverterTests.cs" />
|
||||
@@ -50,15 +30,6 @@
|
||||
<None Update="TestData\OpenAIDefaultResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxRecordResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionResponse.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\ToolboxVersionWithDecorationFields.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-call <c>MeaiUserAgentPolicy</c> exposed via
|
||||
/// <see cref="RequestOptionsExtensions.UserAgentPolicy"/>. The policy is reachable through the
|
||||
/// public <see cref="FoundryAgent"/> constructors (which add it to the internally-built
|
||||
/// <see cref="Azure.AI.Projects.AIProjectClient"/>'s pipeline), so its behavior is part of the
|
||||
/// public API surface.
|
||||
/// </summary>
|
||||
public sealed class RequestOptionsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
|
||||
// (by the polyfill UserAgentResponsesClient → HostedAgentUserAgentPolicy).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance — the policy is stateless and shared.
|
||||
var first = RequestOptionsExtensions.UserAgentPolicy;
|
||||
var second = RequestOptionsExtensions.UserAgentPolicy;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version,
|
||||
// which is a measurable telemetry regression.
|
||||
var attr = typeof(RequestOptionsExtensions).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@ internal static class TestDataUtil
|
||||
private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json");
|
||||
private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json");
|
||||
private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json");
|
||||
private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json");
|
||||
private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json");
|
||||
private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json");
|
||||
|
||||
private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\"";
|
||||
|
||||
@@ -165,19 +162,4 @@ internal static class TestDataUtil
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox record response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbox version response JSON with decoration fields on tools.
|
||||
/// </summary>
|
||||
public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson;
|
||||
}
|
||||
|
||||
+3
-5
@@ -26,7 +26,6 @@ pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.sample("03_reliable_streaming"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
pytest.mark.skip(reason="Temp disabled to fix test instability - needs investigation into root cause"),
|
||||
]
|
||||
|
||||
|
||||
@@ -56,12 +55,11 @@ class TestSampleReliableStreaming:
|
||||
# Wait a moment for the agent to start writing to Redis
|
||||
time.sleep(2)
|
||||
|
||||
# Stream response from Redis with shorter timeout
|
||||
# Note: We use text/plain to avoid SSE parsing complexity
|
||||
# Stream response from Redis with longer timeout to account for LLM latency
|
||||
stream_response = requests.get(
|
||||
f"{self.stream_url}/{thread_id}",
|
||||
headers={"Accept": "text/plain"},
|
||||
timeout=30, # Shorter timeout for test
|
||||
timeout=60,
|
||||
)
|
||||
assert stream_response.status_code == 200
|
||||
|
||||
@@ -83,7 +81,7 @@ class TestSampleReliableStreaming:
|
||||
stream_response = requests.get(
|
||||
f"{self.stream_url}/{thread_id}",
|
||||
headers={"Accept": "text/event-stream"},
|
||||
timeout=30, # Shorter timeout
|
||||
timeout=60,
|
||||
)
|
||||
assert stream_response.status_code == 200
|
||||
content_type = stream_response.headers.get("content-type", "")
|
||||
|
||||
@@ -42,7 +42,7 @@ class TestWorkflowParallel:
|
||||
self.base_url = base_url
|
||||
self.helper = sample_helper
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
|
||||
def test_parallel_workflow_document_analysis(self) -> None:
|
||||
"""Test parallel workflow with a standard document."""
|
||||
payload = {
|
||||
@@ -71,7 +71,7 @@ class TestWorkflowParallel:
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
|
||||
def test_parallel_workflow_short_document(self) -> None:
|
||||
"""Test parallel workflow with a short document."""
|
||||
payload = {
|
||||
@@ -91,7 +91,7 @@ class TestWorkflowParallel:
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
|
||||
def test_parallel_workflow_technical_document(self) -> None:
|
||||
"""Test parallel workflow with a technical document."""
|
||||
payload = {
|
||||
@@ -115,7 +115,7 @@ class TestWorkflowParallel:
|
||||
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300)
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
|
||||
@pytest.mark.skip(reason="Causes timeouts.")
|
||||
@pytest.mark.skip(reason="xdist distributes module tests across workers, each spawning a func process")
|
||||
def test_workflow_status_endpoint(self) -> None:
|
||||
"""Test that the workflow status endpoint works correctly."""
|
||||
payload = {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload
|
||||
|
||||
@@ -73,6 +74,54 @@ logger = logging.getLogger("agent_framework.claude")
|
||||
TOOLS_MCP_SERVER_NAME = "_agent_framework_tools"
|
||||
|
||||
|
||||
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
|
||||
"""Callback invoked by the agent before executing a FunctionTool that requires approval.
|
||||
|
||||
The callback receives a ``FunctionCallContent`` describing the pending call
|
||||
(``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
|
||||
to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
|
||||
return values are supported.
|
||||
|
||||
The Claude Agent SDK manages its own tool-calling loop, so the framework cannot
|
||||
round-trip a ``FunctionApprovalRequestContent`` / ``FunctionApprovalResponseContent``
|
||||
pair the way the standard chat-client pipeline does. This callback is the
|
||||
agent-level enforcement point for tools declared with
|
||||
``approval_mode="always_require"``: when no callback is configured the agent
|
||||
denies these calls by default.
|
||||
"""
|
||||
|
||||
|
||||
async def _resolve_function_approval(
|
||||
callback: FunctionApprovalCallback | None,
|
||||
func_tool: FunctionTool,
|
||||
arguments: Mapping[str, Any] | None,
|
||||
) -> bool:
|
||||
"""Run the agent-level approval callback for a pending tool call.
|
||||
|
||||
Returns ``True`` only when ``callback`` is configured and explicitly returns
|
||||
a truthy value. A missing callback or any callback failure is treated as a
|
||||
denial so the secure-by-default policy holds even if the user code raises.
|
||||
"""
|
||||
if callback is None:
|
||||
return False
|
||||
request = Content.from_function_call(
|
||||
call_id=f"af-claude-approval::{func_tool.name}",
|
||||
name=func_tool.name,
|
||||
arguments=None if arguments is None else dict(arguments),
|
||||
)
|
||||
try:
|
||||
outcome = callback(request)
|
||||
if inspect.isawaitable(outcome):
|
||||
outcome = await outcome
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"on_function_approval callback raised for tool '%s'; denying execution.",
|
||||
func_tool.name,
|
||||
)
|
||||
return False
|
||||
return bool(outcome)
|
||||
|
||||
|
||||
class ClaudeAgentSettings(TypedDict, total=False):
|
||||
"""Claude Agent settings.
|
||||
|
||||
@@ -175,6 +224,13 @@ class ClaudeAgentOptions(TypedDict, total=False):
|
||||
effort: Literal["low", "medium", "high", "max"]
|
||||
"""Effort level for thinking depth."""
|
||||
|
||||
on_function_approval: FunctionApprovalCallback
|
||||
"""Approval callback for ``FunctionTool`` instances declared with
|
||||
``approval_mode="always_require"``. The callback is awaited (sync or async)
|
||||
inside the SDK tool-handler before the tool is executed; a falsy return
|
||||
value denies the call. If omitted, calls to such tools are denied with an
|
||||
explanatory message returned to the model."""
|
||||
|
||||
|
||||
OptionsT = TypeVar(
|
||||
"OptionsT",
|
||||
@@ -275,6 +331,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
max_turns = opts.pop("max_turns", None)
|
||||
max_budget_usd = opts.pop("max_budget_usd", None)
|
||||
self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {}
|
||||
self._function_approval_handler: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
|
||||
|
||||
# Load settings from environment and options
|
||||
self._settings = load_settings(
|
||||
@@ -487,10 +544,29 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
Returns:
|
||||
An SdkMcpTool instance.
|
||||
"""
|
||||
approval_handler = self._function_approval_handler
|
||||
requires_approval = func_tool.approval_mode == "always_require"
|
||||
|
||||
async def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handler that invokes the FunctionTool."""
|
||||
try:
|
||||
if requires_approval and not await _resolve_function_approval(approval_handler, func_tool, args):
|
||||
deny_text = (
|
||||
f"Tool '{func_tool.name}' requires human approval "
|
||||
"(approval_mode='always_require') and the request was denied."
|
||||
if approval_handler is not None
|
||||
else (
|
||||
f"Tool '{func_tool.name}' requires human approval "
|
||||
"(approval_mode='always_require') but no on_function_approval "
|
||||
"callback is configured on the agent; the request was denied."
|
||||
)
|
||||
)
|
||||
logger.warning(
|
||||
"Denying execution of tool '%s' (approval_mode='always_require', %s)",
|
||||
func_tool.name,
|
||||
"callback denied" if approval_handler is not None else "no callback configured",
|
||||
)
|
||||
return {"content": [{"type": "text", "text": deny_text}]}
|
||||
if func_tool.input_model:
|
||||
args_instance = func_tool.input_model(**args)
|
||||
result = await func_tool.invoke(arguments=args_instance)
|
||||
@@ -538,6 +614,13 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
if not options or not self._client:
|
||||
return
|
||||
|
||||
if "on_function_approval" in options:
|
||||
raise ValueError(
|
||||
"on_function_approval is a security-sensitive option and must be set "
|
||||
"via default_options at agent construction time. It cannot be overridden "
|
||||
"per run."
|
||||
)
|
||||
|
||||
if "model" in options:
|
||||
await self._client.set_model(options["model"])
|
||||
|
||||
|
||||
@@ -602,6 +602,141 @@ class TestClaudeAgentToolConversion:
|
||||
assert "Something went wrong" in result["content"][0]["text"]
|
||||
|
||||
|
||||
# region Test ClaudeAgent Function Approval Enforcement
|
||||
|
||||
|
||||
class TestClaudeAgentFunctionApproval:
|
||||
"""Tests that ``approval_mode='always_require'`` is enforced at the agent boundary."""
|
||||
|
||||
async def test_handler_denies_when_no_callback_configured(self) -> None:
|
||||
"""Approval-required tool must be denied without executing when no callback is set."""
|
||||
invocations: list[Any] = []
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(path: str) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(path)
|
||||
return f"deleted {path}"
|
||||
|
||||
agent = ClaudeAgent()
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"path": "/critical"})
|
||||
|
||||
assert invocations == []
|
||||
text = result["content"][0]["text"]
|
||||
assert "requires human approval" in text
|
||||
assert "no on_function_approval callback is configured" in text
|
||||
|
||||
async def test_handler_denies_when_callback_returns_false(self) -> None:
|
||||
"""Falsy callback return value must deny the call and skip execution."""
|
||||
invocations: list[Any] = []
|
||||
seen: list[Content] = []
|
||||
|
||||
def deny(call: Content) -> bool:
|
||||
seen.append(call)
|
||||
return False
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(path: str) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(path)
|
||||
return f"deleted {path}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": deny})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"path": "/critical"})
|
||||
|
||||
assert invocations == []
|
||||
assert len(seen) == 1
|
||||
assert seen[0].type == "function_call"
|
||||
assert seen[0].name == "dangerous" # type: ignore[attr-defined]
|
||||
assert seen[0].arguments == {"path": "/critical"} # type: ignore[attr-defined]
|
||||
assert "denied" in result["content"][0]["text"].lower()
|
||||
|
||||
async def test_handler_executes_when_callback_returns_true(self) -> None:
|
||||
"""Truthy callback return value must allow the tool to execute normally."""
|
||||
|
||||
def approve(call: Content) -> bool:
|
||||
return True
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def guarded(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
return f"result={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": approve})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(guarded) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 42})
|
||||
|
||||
assert result["content"][0]["text"] == "result=42"
|
||||
|
||||
async def test_handler_supports_async_callback(self) -> None:
|
||||
"""Async callback must be awaited and respected."""
|
||||
|
||||
async def approve(call: Content) -> bool:
|
||||
return True
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def guarded(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
return f"async={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": approve})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(guarded) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 7})
|
||||
|
||||
assert result["content"][0]["text"] == "async=7"
|
||||
|
||||
async def test_callback_failure_denies_safely(self) -> None:
|
||||
"""A callback that raises must result in denial, not in tool execution."""
|
||||
invocations: list[Any] = []
|
||||
|
||||
def boom(call: Content) -> bool:
|
||||
raise RuntimeError("nope")
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(x)
|
||||
return f"x={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": boom})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 1})
|
||||
|
||||
assert invocations == []
|
||||
assert "denied" in result["content"][0]["text"].lower()
|
||||
|
||||
async def test_handler_does_not_invoke_callback_for_never_require(self) -> None:
|
||||
"""Tools without approval_mode='always_require' must not trigger the callback."""
|
||||
callback_calls: list[Any] = []
|
||||
|
||||
def approve(call: Content) -> bool:
|
||||
callback_calls.append(call)
|
||||
return True
|
||||
|
||||
@tool
|
||||
def safe(x: int) -> str:
|
||||
"""A tool that does not require approval."""
|
||||
return f"safe={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": approve})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(safe) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 5})
|
||||
|
||||
assert callback_calls == []
|
||||
assert result["content"][0]["text"] == "safe=5"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Test ClaudeAgent Permissions
|
||||
|
||||
|
||||
@@ -786,6 +921,20 @@ class TestApplyRuntimeOptions:
|
||||
mock_client.set_model.assert_not_called()
|
||||
mock_client.set_permission_mode.assert_not_called()
|
||||
|
||||
async def test_apply_runtime_on_function_approval_rejected(self) -> None:
|
||||
"""on_function_approval cannot be overridden per run."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_model = AsyncMock()
|
||||
mock_client.set_permission_mode = AsyncMock()
|
||||
|
||||
agent = ClaudeAgent()
|
||||
agent._client = mock_client # type: ignore[reportPrivateUsage]
|
||||
|
||||
with pytest.raises(ValueError, match="on_function_approval"):
|
||||
await agent._apply_runtime_options({"on_function_approval": lambda _c: True}) # type: ignore[reportPrivateUsage]
|
||||
mock_client.set_model.assert_not_called()
|
||||
mock_client.set_permission_mode.assert_not_called()
|
||||
|
||||
|
||||
# region Test ClaudeAgent Structured Output
|
||||
|
||||
|
||||
@@ -21,12 +21,24 @@ _IMPORTS = [
|
||||
"AgentFactory",
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
@@ -4,12 +4,24 @@ from agent_framework_declarative import (
|
||||
AgentExternalInputRequest,
|
||||
AgentExternalInputResponse,
|
||||
AgentFactory,
|
||||
DeclarativeActionError,
|
||||
DeclarativeLoaderError,
|
||||
DeclarativeWorkflowError,
|
||||
DefaultHttpRequestHandler,
|
||||
DefaultMCPToolHandler,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
MCPToolApprovalRequest,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
ProviderLookupError,
|
||||
ProviderTypeMapping,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
WorkflowState,
|
||||
)
|
||||
@@ -18,12 +30,24 @@ __all__ = [
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentFactory",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,9 @@ YAML/JSON-based declarative agent and workflow definitions.
|
||||
- **`WorkflowFactory`** - Creates workflows from declarative definitions
|
||||
- **`WorkflowState`** - State management for declarative workflows
|
||||
- **`ProviderTypeMapping`** - Maps provider types to implementations
|
||||
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** - Error types
|
||||
- **`HttpRequestHandler`** / **`DefaultHttpRequestHandler`** - Pluggable HTTP transport for the `HttpRequestAction` declarative action (configured via `WorkflowFactory(http_request_handler=...)`)
|
||||
- **`MCPToolHandler`** / **`DefaultMCPToolHandler`** - Pluggable MCP transport for the `InvokeMcpTool` declarative action (configured via `WorkflowFactory(mcp_tool_handler=...)`)
|
||||
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** / **`DeclarativeWorkflowError`** / **`DeclarativeActionError`** - Error types
|
||||
|
||||
## External Input Handling
|
||||
|
||||
|
||||
@@ -6,9 +6,21 @@ from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError,
|
||||
from ._workflows import (
|
||||
AgentExternalInputRequest,
|
||||
AgentExternalInputResponse,
|
||||
DeclarativeActionError,
|
||||
DeclarativeWorkflowError,
|
||||
DefaultHttpRequestHandler,
|
||||
DefaultMCPToolHandler,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
MCPToolApprovalRequest,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
WorkflowState,
|
||||
)
|
||||
@@ -22,12 +34,24 @@ __all__ = [
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentFactory",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
"__version__",
|
||||
|
||||
@@ -25,6 +25,7 @@ from ._declarative_base import (
|
||||
LoopIterationResult,
|
||||
)
|
||||
from ._declarative_builder import ALL_ACTION_EXECUTORS, DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeActionError, DeclarativeWorkflowError
|
||||
from ._executors_agents import (
|
||||
AGENT_ACTION_EXECUTORS,
|
||||
AGENT_REGISTRY_KEY,
|
||||
@@ -67,6 +68,15 @@ from ._executors_external_input import (
|
||||
RequestExternalInputExecutor,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
from ._executors_http import (
|
||||
HTTP_ACTION_EXECUTORS,
|
||||
HttpRequestActionExecutor,
|
||||
)
|
||||
from ._executors_mcp import (
|
||||
MCP_ACTION_EXECUTORS,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
)
|
||||
from ._executors_tools import (
|
||||
FUNCTION_TOOL_REGISTRY_KEY,
|
||||
TOOL_ACTION_EXECUTORS,
|
||||
@@ -78,7 +88,19 @@ from ._executors_tools import (
|
||||
ToolApprovalState,
|
||||
ToolInvocationResult,
|
||||
)
|
||||
from ._factory import DeclarativeWorkflowError, WorkflowFactory
|
||||
from ._factory import WorkflowFactory
|
||||
from ._http_handler import (
|
||||
DefaultHttpRequestHandler,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
)
|
||||
from ._mcp_handler import (
|
||||
DefaultMCPToolHandler,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
)
|
||||
from ._state import WorkflowState
|
||||
|
||||
__all__ = [
|
||||
@@ -90,6 +112,8 @@ __all__ = [
|
||||
"DECLARATIVE_STATE_KEY",
|
||||
"EXTERNAL_INPUT_EXECUTORS",
|
||||
"FUNCTION_TOOL_REGISTRY_KEY",
|
||||
"HTTP_ACTION_EXECUTORS",
|
||||
"MCP_ACTION_EXECUTORS",
|
||||
"TOOL_ACTION_EXECUTORS",
|
||||
"TOOL_APPROVAL_STATE_KEY",
|
||||
"TOOL_REGISTRY_KEY",
|
||||
@@ -106,12 +130,15 @@ __all__ = [
|
||||
"ContinueLoopExecutor",
|
||||
"ConversationData",
|
||||
"CreateConversationExecutor",
|
||||
"DeclarativeActionError",
|
||||
"DeclarativeActionExecutor",
|
||||
"DeclarativeMessage",
|
||||
"DeclarativeStateData",
|
||||
"DeclarativeWorkflowBuilder",
|
||||
"DeclarativeWorkflowError",
|
||||
"DeclarativeWorkflowState",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"EmitEventExecutor",
|
||||
"EndConversationExecutor",
|
||||
"EndWorkflowExecutor",
|
||||
@@ -120,11 +147,20 @@ __all__ = [
|
||||
"ExternalLoopState",
|
||||
"ForeachInitExecutor",
|
||||
"ForeachNextExecutor",
|
||||
"HttpRequestActionExecutor",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"InvokeAzureAgentExecutor",
|
||||
"InvokeFunctionToolExecutor",
|
||||
"InvokeMcpToolActionExecutor",
|
||||
"JoinExecutor",
|
||||
"LoopControl",
|
||||
"LoopIterationResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"QuestionExecutor",
|
||||
"RequestExternalInputExecutor",
|
||||
"ResetVariableExecutor",
|
||||
|
||||
+45
@@ -26,6 +26,7 @@ from ._declarative_base import (
|
||||
DeclarativeActionExecutor,
|
||||
LoopIterationResult,
|
||||
)
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._executors_agents import AGENT_ACTION_EXECUTORS, InvokeAzureAgentExecutor
|
||||
from ._executors_basic import BASIC_ACTION_EXECUTORS
|
||||
from ._executors_control_flow import (
|
||||
@@ -39,7 +40,11 @@ from ._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
|
||||
from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
|
||||
from ._executors_mcp import MCP_ACTION_EXECUTORS, InvokeMcpToolActionExecutor
|
||||
from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
|
||||
from ._http_handler import HttpRequestHandler
|
||||
from ._mcp_handler import MCPToolHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +56,8 @@ ALL_ACTION_EXECUTORS = {
|
||||
**AGENT_ACTION_EXECUTORS,
|
||||
**EXTERNAL_INPUT_EXECUTORS,
|
||||
**TOOL_ACTION_EXECUTORS,
|
||||
**HTTP_ACTION_EXECUTORS,
|
||||
**MCP_ACTION_EXECUTORS,
|
||||
}
|
||||
|
||||
# Action kinds that terminate control flow (no fall-through to successor)
|
||||
@@ -85,6 +92,8 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"WaitForHumanInput": ["variable"],
|
||||
"EmitEvent": ["event"],
|
||||
"InvokeFunctionTool": ["functionName"],
|
||||
"HttpRequestAction": ["url"],
|
||||
"InvokeMcpTool": ["serverUrl", "toolName"],
|
||||
}
|
||||
|
||||
# Alternate field names that satisfy required field requirements
|
||||
@@ -129,6 +138,8 @@ class DeclarativeWorkflowBuilder:
|
||||
checkpoint_storage: Any | None = None,
|
||||
validate: bool = True,
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
):
|
||||
"""Initialize the builder.
|
||||
|
||||
@@ -141,6 +152,12 @@ class DeclarativeWorkflowBuilder:
|
||||
validate: Whether to validate the workflow definition before building (default: True)
|
||||
max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
|
||||
field, then to the core default (100).
|
||||
http_request_handler: Handler used to dispatch HttpRequestAction requests.
|
||||
Must be supplied when the workflow contains any HttpRequestAction;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
|
||||
Must be supplied when the workflow contains any InvokeMcpTool;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
"""
|
||||
self._yaml_def = yaml_definition
|
||||
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
|
||||
@@ -152,6 +169,8 @@ class DeclarativeWorkflowBuilder:
|
||||
self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
|
||||
self._validate = validate
|
||||
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
|
||||
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
|
||||
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
|
||||
@@ -458,6 +477,32 @@ class DeclarativeWorkflowBuilder:
|
||||
executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents)
|
||||
elif kind == "InvokeFunctionTool":
|
||||
executor = InvokeFunctionToolExecutor(action_def, id=action_id, tools=self._tools)
|
||||
elif kind == "HttpRequestAction":
|
||||
if self._http_request_handler is None:
|
||||
raise DeclarativeWorkflowError(
|
||||
f"Workflow defines HttpRequestAction '{action_id}' but no "
|
||||
"http_request_handler was supplied to WorkflowFactory. Pass "
|
||||
"http_request_handler=DefaultHttpRequestHandler() (or a custom "
|
||||
"implementation) to enable HTTP requests."
|
||||
)
|
||||
executor = HttpRequestActionExecutor(
|
||||
action_def,
|
||||
id=action_id,
|
||||
http_request_handler=self._http_request_handler,
|
||||
)
|
||||
elif kind == "InvokeMcpTool":
|
||||
if self._mcp_tool_handler is None:
|
||||
raise DeclarativeWorkflowError(
|
||||
f"Workflow defines InvokeMcpTool '{action_id}' but no "
|
||||
"mcp_tool_handler was supplied to WorkflowFactory. Pass "
|
||||
"mcp_tool_handler=DefaultMCPToolHandler() (or a custom "
|
||||
"implementation) to enable MCP tool invocations."
|
||||
)
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
action_def,
|
||||
id=action_id,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
)
|
||||
else:
|
||||
executor = executor_class(action_def, id=action_id)
|
||||
self._executors[action_id] = executor
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Error types for declarative workflow executor modules.
|
||||
|
||||
This module exists so that executor modules and the builder (e.g.
|
||||
``_executors_http``, ``_declarative_builder``) can raise declarative-specific
|
||||
exceptions without importing from ``_factory``. ``_factory`` imports
|
||||
``_declarative_builder`` which imports the executor modules; pulling
|
||||
:class:`DeclarativeWorkflowError` from ``_factory`` into an executor or
|
||||
builder module would therefore introduce a circular import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_framework.exceptions import WorkflowException
|
||||
|
||||
|
||||
class DeclarativeWorkflowError(WorkflowException):
|
||||
"""Raised for build-time / factory-level declarative workflow errors.
|
||||
|
||||
Used for YAML parsing/validation issues, missing configuration (e.g. an
|
||||
HTTP request handler not supplied for a workflow that contains an
|
||||
``HttpRequestAction``), and other errors detected before workflow
|
||||
execution begins.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeclarativeActionError(WorkflowException):
|
||||
"""Raised when a declarative action fails at run time.
|
||||
|
||||
Used by executor modules for runtime failures (e.g. transport errors,
|
||||
non-2xx responses from :class:`HttpRequestActionExecutor`). Build-time and
|
||||
factory-level errors continue to use :class:`DeclarativeWorkflowError`.
|
||||
"""
|
||||
|
||||
pass
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Executor for the ``HttpRequestAction`` declarative action.
|
||||
|
||||
Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
|
||||
configured :class:`HttpRequestHandler`, parses the response body, and assigns
|
||||
the parsed body and response headers to the declared state paths.
|
||||
|
||||
Security note: response bodies can echo secrets and may be very large. Diagnostic
|
||||
messages produced for non-2xx responses truncate the body to 256 characters and
|
||||
collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeWorkflowState,
|
||||
)
|
||||
from ._errors import DeclarativeActionError
|
||||
from ._http_handler import HttpRequestHandler, HttpRequestInfo, HttpRequestResult
|
||||
|
||||
__all__ = [
|
||||
"HTTP_ACTION_EXECUTORS",
|
||||
"HttpRequestActionExecutor",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BODY_DIAGNOSTIC_LENGTH = 256
|
||||
_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
|
||||
|
||||
|
||||
# Body discriminator aliases. Long forms match the .NET object-model type
|
||||
# names so YAML produced by .NET round-trips. Short forms are the .NET YAML
|
||||
# convention used in test fixtures.
|
||||
_BODY_KIND_JSON = {"json", "JsonRequestContent"}
|
||||
_BODY_KIND_RAW = {"raw", "RawRequestContent"}
|
||||
_BODY_KIND_NONE = {"none", "NoRequestContent"}
|
||||
|
||||
|
||||
def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
||||
"""Extract a state path from ``response``/``responseHeaders`` field.
|
||||
|
||||
Supports two YAML shapes (matches .NET serialization round-trips):
|
||||
|
||||
- ``response: Local.MyVar`` (plain string).
|
||||
- ``response: { path: Local.MyVar }`` (object form).
|
||||
"""
|
||||
value = action_def.get(key)
|
||||
if isinstance(value, str):
|
||||
return value or None
|
||||
if isinstance(value, Mapping):
|
||||
path = value.get("path") # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return path if isinstance(path, str) and path else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_body_for_diagnostics(body: str | None) -> str:
|
||||
"""Truncate and sanitise a response body for inclusion in error messages.
|
||||
|
||||
Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
|
||||
|
||||
- Empty/None -> empty string.
|
||||
- Replaces CR/LF/TAB with spaces.
|
||||
- Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
|
||||
head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
|
||||
sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
||||
return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
|
||||
|
||||
|
||||
def _parse_response_body(body: str | None) -> Any:
|
||||
"""Parse an HTTP response body the same way the .NET executor does.
|
||||
|
||||
JSON-first: if the body parses as JSON, the parsed value is returned. Other
|
||||
bodies are returned as the raw string. Empty/None bodies return ``None``.
|
||||
"""
|
||||
if body is None or body == "":
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return body
|
||||
|
||||
|
||||
def _format_query_value(value: Any) -> str | None:
|
||||
"""Format a query-parameter value for URL inclusion.
|
||||
|
||||
Mirrors .NET ``FormatQueryValue``: ``None`` is dropped, ``bool`` becomes
|
||||
lower-case ``"true"``/``"false"``, numerics use invariant ``str()``, and
|
||||
other values fall through to ``str()``.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
|
||||
"""Return the configured conversation messages path, if any.
|
||||
|
||||
Returns ``System.conversations.{evaluated_id}.messages`` when a
|
||||
``conversation_id_expr`` is configured and evaluates to a non-empty value.
|
||||
Returns ``None`` when no conversation id expression is configured or when
|
||||
the expression evaluates to ``None`` or an empty string (matches .NET
|
||||
``GetConversationId`` behaviour where empty becomes ``null`` and the
|
||||
response is not appended).
|
||||
"""
|
||||
if not conversation_id_expr:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(conversation_id_expr)
|
||||
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
|
||||
return None
|
||||
return f"System.conversations.{evaluated}.messages"
|
||||
|
||||
|
||||
class HttpRequestActionExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ``HttpRequestAction`` declarative action.
|
||||
|
||||
Dispatches through the supplied :class:`HttpRequestHandler` and:
|
||||
|
||||
- Parses the response body (JSON-first, raw string fall-back).
|
||||
- Assigns the parsed body to ``response`` path (if configured).
|
||||
- Folds multi-value response headers (comma-joined) and assigns them to
|
||||
``responseHeaders`` path (if configured).
|
||||
- On 2xx with non-empty body and a configured ``conversationId``, appends
|
||||
an Assistant :class:`agent_framework.Message` to
|
||||
``System.conversations.{id}.messages``.
|
||||
- On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
|
||||
:class:`DeclarativeActionError` with a status-coded message containing a
|
||||
truncated/sanitised body preview.
|
||||
|
||||
Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
|
||||
``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
|
||||
is intentionally NOT caught so that workflow cancellation propagates.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
*,
|
||||
id: str | None = None,
|
||||
http_request_handler: HttpRequestHandler,
|
||||
) -> None:
|
||||
"""Create an HTTP request action executor.
|
||||
|
||||
Args:
|
||||
action_def: Parsed ``HttpRequestAction`` YAML dict.
|
||||
id: Optional executor id (defaults to action id or generated).
|
||||
http_request_handler: Handler used to dispatch HTTP requests.
|
||||
Required: the builder enforces presence at workflow-build time.
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._http_request_handler = http_request_handler
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Execute the HTTP request action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
method = self._get_method(state)
|
||||
url = self._get_url(state)
|
||||
headers = self._get_headers(state)
|
||||
query_parameters = self._get_query_parameters(state)
|
||||
body, body_content_type = self._get_body(state)
|
||||
timeout_ms = self._get_timeout_ms(state)
|
||||
conversation_id_expr = self._action_def.get("conversationId")
|
||||
connection_name = self._get_connection_name(state)
|
||||
|
||||
info = HttpRequestInfo(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers or {},
|
||||
query_parameters=query_parameters or {},
|
||||
body=body,
|
||||
body_content_type=body_content_type,
|
||||
timeout_ms=timeout_ms,
|
||||
connection_name=connection_name,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._http_request_handler.send(info)
|
||||
except (httpx.TimeoutException, TimeoutError) as exc:
|
||||
raise DeclarativeActionError(f"HTTP request to '{url}' timed out.") from exc
|
||||
except DeclarativeActionError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
|
||||
except Exception as exc:
|
||||
# Custom HttpRequestHandler implementations may raise arbitrary
|
||||
# exception types. Wrap them in DeclarativeActionError so workflow
|
||||
# error handling stays uniform regardless of transport. Note that
|
||||
# ``asyncio.CancelledError`` is a ``BaseException`` (not
|
||||
# ``Exception``) and so still propagates unmodified, preserving
|
||||
# workflow-cancellation semantics.
|
||||
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
|
||||
|
||||
if result.is_success_status_code:
|
||||
self._assign_response(state, result)
|
||||
self._assign_response_headers(state, result)
|
||||
self._append_response_to_conversation(state, conversation_id_expr, result.body)
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
# Non-success path: still publish headers diagnostically, then raise.
|
||||
self._assign_response_headers(state, result)
|
||||
body_preview = _format_body_for_diagnostics(result.body)
|
||||
if body_preview:
|
||||
message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
|
||||
else:
|
||||
message = f"HTTP request to '{url}' failed with status code {result.status_code}."
|
||||
raise DeclarativeActionError(message)
|
||||
|
||||
# ----- Field resolution ----------------------------------------------------
|
||||
|
||||
def _get_method(self, state: DeclarativeWorkflowState) -> str:
|
||||
method = self._action_def.get("method")
|
||||
evaluated = state.eval_if_expression(method) if method is not None else None
|
||||
if not evaluated:
|
||||
return "GET"
|
||||
return str(evaluated).upper()
|
||||
|
||||
def _get_url(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("url")
|
||||
if raw is None:
|
||||
raise ValueError("HttpRequestAction requires a 'url' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("HttpRequestAction 'url' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
|
||||
raw_headers = self._action_def.get("headers")
|
||||
if not isinstance(raw_headers, Mapping) or not raw_headers:
|
||||
return None
|
||||
result: dict[str, str] = {}
|
||||
for key, value in raw_headers.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
if evaluated is None:
|
||||
continue
|
||||
text = str(evaluated)
|
||||
if not text:
|
||||
continue
|
||||
result[key] = text
|
||||
return result or None
|
||||
|
||||
def _get_query_parameters(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
|
||||
raw_params = self._action_def.get("queryParameters")
|
||||
if not isinstance(raw_params, Mapping) or not raw_params:
|
||||
return None
|
||||
result: dict[str, str] = {}
|
||||
for key, value in raw_params.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key or value is None:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
formatted = _format_query_value(evaluated)
|
||||
if formatted is not None:
|
||||
result[key] = formatted
|
||||
return result or None
|
||||
|
||||
def _get_body(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None]:
|
||||
raw_body = self._action_def.get("body")
|
||||
if raw_body is None:
|
||||
return None, None
|
||||
if not isinstance(raw_body, Mapping):
|
||||
raise ValueError(
|
||||
"HttpRequestAction 'body' must be a mapping with a 'kind' field (json, raw) or omitted entirely."
|
||||
)
|
||||
|
||||
kind_value: Any = raw_body.get("kind") or raw_body.get("$kind") # type: ignore[reportUnknownMemberType]
|
||||
if kind_value is None:
|
||||
raise ValueError(
|
||||
"HttpRequestAction 'body' is missing 'kind'. Use 'json', 'raw', or omit 'body' for no request body."
|
||||
)
|
||||
if not isinstance(kind_value, str):
|
||||
raise ValueError(f"HttpRequestAction 'body.kind' must be a string, got {kind_value!r}.")
|
||||
|
||||
if kind_value in _BODY_KIND_NONE:
|
||||
return None, None
|
||||
|
||||
if kind_value in _BODY_KIND_JSON:
|
||||
content_expr: Any = raw_body.get("content") # type: ignore[reportUnknownMemberType]
|
||||
if content_expr is None:
|
||||
return None, None
|
||||
evaluated = state.eval_if_expression(content_expr)
|
||||
try:
|
||||
body_text = json.dumps(evaluated, default=str)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"HttpRequestAction 'body.content' could not be serialised as JSON: {exc}") from exc
|
||||
return body_text, "application/json"
|
||||
|
||||
if kind_value in _BODY_KIND_RAW:
|
||||
content_expr = raw_body.get("content") # type: ignore[reportUnknownMemberType]
|
||||
content_type_expr: Any = raw_body.get("contentType") # type: ignore[reportUnknownMemberType]
|
||||
content: str | None = None
|
||||
if content_expr is not None:
|
||||
evaluated = state.eval_if_expression(content_expr)
|
||||
content = None if evaluated is None else str(evaluated)
|
||||
content_type: str | None = None
|
||||
if content_type_expr is not None:
|
||||
ct_eval = state.eval_if_expression(content_type_expr)
|
||||
ct_text = None if ct_eval is None else str(ct_eval)
|
||||
content_type = ct_text or None
|
||||
# Match .NET RawRequestContent semantics: when a raw body is sent
|
||||
# without an explicit content type, default to text/plain so the
|
||||
# request is interpretable by servers.
|
||||
if content is not None and not content_type:
|
||||
content_type = "text/plain"
|
||||
return content, content_type
|
||||
|
||||
raise ValueError(
|
||||
f"HttpRequestAction 'body.kind' has unsupported value '{kind_value}'. "
|
||||
"Expected one of: json, raw, JsonRequestContent, RawRequestContent, "
|
||||
"NoRequestContent."
|
||||
)
|
||||
|
||||
def _get_timeout_ms(self, state: DeclarativeWorkflowState) -> int | None:
|
||||
raw = self._action_def.get("requestTimeoutInMilliseconds")
|
||||
if raw is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if evaluated is None:
|
||||
return None
|
||||
try:
|
||||
value = int(evaluated)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
"HttpRequestAction: ignoring non-numeric requestTimeoutInMilliseconds=%r",
|
||||
evaluated,
|
||||
)
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
connection = self._action_def.get("connection")
|
||||
if not isinstance(connection, Mapping):
|
||||
return None
|
||||
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
|
||||
if name_expr is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(name_expr)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
# ----- Result handling -----------------------------------------------------
|
||||
|
||||
def _assign_response(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
|
||||
path = _get_path(self._action_def, "response")
|
||||
if path is None:
|
||||
return
|
||||
state.set(path, _parse_response_body(result.body))
|
||||
|
||||
def _assign_response_headers(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
|
||||
path = _get_path(self._action_def, "responseHeaders")
|
||||
if path is None:
|
||||
return
|
||||
if not result.headers:
|
||||
state.set(path, None)
|
||||
return
|
||||
# Fold multi-value headers with commas (standard HTTP folding) only at
|
||||
# assignment time. The raw multi-value dict on HttpRequestResult.headers
|
||||
# is left untouched so callers/tests can inspect duplicates.
|
||||
flattened: dict[str, str] = {}
|
||||
for key, values in result.headers.items():
|
||||
flattened[key] = ",".join(values)
|
||||
state.set(path, flattened)
|
||||
|
||||
def _append_response_to_conversation(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
conversation_id_expr: str | None,
|
||||
body: str,
|
||||
) -> None:
|
||||
if not body:
|
||||
return
|
||||
messages_path = _get_messages_path(state, conversation_id_expr)
|
||||
if messages_path is None:
|
||||
return
|
||||
# Mirrors InvokeAzureAgentExecutor: rely on state.append to lazily
|
||||
# create the conversation entry. Avoids re-parsing the id back out
|
||||
# of the dotted path string.
|
||||
message = Message(role="assistant", contents=[body])
|
||||
state.append(messages_path, message)
|
||||
|
||||
|
||||
HTTP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"HttpRequestAction": HttpRequestActionExecutor,
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Executor for the ``InvokeMcpTool`` declarative action.
|
||||
|
||||
Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
|
||||
the configured :class:`MCPToolHandler`, parses tool outputs, and routes
|
||||
results to the configured ``output.{result, messages, autoSend}`` paths and
|
||||
optional conversation history. Supports a human-in-loop approval flow via
|
||||
``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
|
||||
|
||||
Security notes:
|
||||
|
||||
- The executor never echoes header VALUES (auth tokens, API keys) into the
|
||||
approval request — only header NAMES are surfaced to the caller. This
|
||||
matches the security posture of :mod:`._executors_http` (which never logs
|
||||
request headers either) and prevents secrets from leaking through workflow
|
||||
events that are typically observable to operators / UIs.
|
||||
- ``_MCPToolApprovalState`` snapshots the EVALUATED values for non-secret
|
||||
fields (server URL, tool name, arguments) at approval-request time so that
|
||||
subsequent state mutations cannot make the executor "approve X then call
|
||||
Y". Headers are stored as the raw expression strings (not evaluated values)
|
||||
so secrets are not persisted in the workflow's checkpoint state. They are
|
||||
re-evaluated on resume.
|
||||
- Tool outputs flow back into agent conversations through ``conversationId``
|
||||
and through Tool-role messages emitted to ``output.messages``. They share
|
||||
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
|
||||
authors must trust the MCP server they invoke.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
Content,
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeWorkflowState,
|
||||
)
|
||||
from ._executors_tools import ToolApprovalResponse
|
||||
from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
|
||||
|
||||
__all__ = [
|
||||
"MCP_ACTION_EXECUTORS",
|
||||
"InvokeMcpToolActionExecutor",
|
||||
"MCPToolApprovalRequest",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MCP_APPROVAL_STATE_KEY = "_mcp_tool_approval_state"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / state types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolApprovalRequest:
|
||||
"""Approval request emitted before invoking an MCP tool.
|
||||
|
||||
Mirrors :class:`agent_framework_declarative.ToolApprovalRequest` but for
|
||||
MCP-style invocations. Only header NAMES are surfaced — header values are
|
||||
intentionally omitted because they typically carry authentication
|
||||
secrets.
|
||||
|
||||
Attributes:
|
||||
request_id: Unique identifier for this approval request. Matches the
|
||||
id workflow event-emitters use.
|
||||
tool_name: Evaluated name of the tool to be invoked.
|
||||
server_url: Evaluated MCP server URL.
|
||||
server_label: Optional human-readable label for diagnostics.
|
||||
arguments: Evaluated arguments to be forwarded to the tool.
|
||||
header_names: Sorted list of outbound header names (no values). Empty
|
||||
when no headers are configured.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
tool_name: str
|
||||
server_url: str
|
||||
server_label: str | None
|
||||
arguments: dict[str, Any]
|
||||
header_names: list[str] = field(default_factory=lambda: [])
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MCPToolApprovalState:
|
||||
"""Internal state saved during the approval yield for resumption.
|
||||
|
||||
Stores **evaluated** values for non-secret fields to prevent
|
||||
"approve X / execute Y" attacks. Stores the raw expression string for
|
||||
``headers`` so that secret values are NOT persisted in checkpoint state;
|
||||
the expressions are re-evaluated against current state on resume.
|
||||
"""
|
||||
|
||||
server_url: str
|
||||
tool_name: str
|
||||
server_label: str | None
|
||||
arguments: dict[str, Any]
|
||||
connection_name: str | None
|
||||
headers_def: Any
|
||||
auto_send: bool
|
||||
conversation_id_expr: str | None
|
||||
output_messages_path: str | None
|
||||
output_result_path: str | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
|
||||
"""Return the configured conversation messages path, if any.
|
||||
|
||||
Returns ``System.conversations.{evaluated_id}.messages`` when a
|
||||
``conversation_id_expr`` is configured and evaluates to a non-empty value.
|
||||
Returns ``None`` when no conversation id expression is configured or when
|
||||
the expression evaluates to ``None`` or an empty string (mirrors .NET
|
||||
``GetConversationId`` behaviour).
|
||||
"""
|
||||
if not conversation_id_expr:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(conversation_id_expr)
|
||||
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
|
||||
return None
|
||||
return f"System.conversations.{evaluated}.messages"
|
||||
|
||||
|
||||
def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
||||
"""Extract a state path from ``output.{key}`` field.
|
||||
|
||||
Supports two YAML shapes:
|
||||
|
||||
- ``output: { result: Local.MyVar }`` — plain string.
|
||||
- ``output: { result: { path: Local.MyVar } }`` — object form.
|
||||
"""
|
||||
output: Any = action_def.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
return None
|
||||
value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
|
||||
if isinstance(value, str):
|
||||
return value or None
|
||||
if isinstance(value, Mapping):
|
||||
path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
|
||||
return path if isinstance(path, str) and path else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_outputs_for_send(parsed_results: list[Any]) -> str:
|
||||
"""Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
|
||||
|
||||
- Empty list → ``""``.
|
||||
- All-string list → newline-joined.
|
||||
- Single element (any type — scalar, dict, list) → JSON-dumped element.
|
||||
This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
|
||||
an MCP tool returns a single scalar JSON value.
|
||||
- Multi-element non-string list → JSON-dump the whole list.
|
||||
"""
|
||||
if not parsed_results:
|
||||
return ""
|
||||
if all(isinstance(item, str) for item in parsed_results):
|
||||
return "\n".join(parsed_results) # type: ignore[arg-type]
|
||||
if len(parsed_results) == 1:
|
||||
return json.dumps(parsed_results[0], ensure_ascii=False)
|
||||
return json.dumps(parsed_results, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ``InvokeMcpTool`` declarative action.
|
||||
|
||||
Dispatches through the supplied :class:`MCPToolHandler` and:
|
||||
|
||||
- Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
|
||||
/ ``headers`` / ``connection.name`` from the action definition.
|
||||
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
|
||||
via ``ctx.request_info()`` and yields. On resume, the response is
|
||||
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
|
||||
no tool call is made.
|
||||
- On success: parses each :class:`agent_framework.Content` output (text →
|
||||
JSON-first / data / uri → URI string) and assigns the parsed list to
|
||||
``output.result``. Builds a single Tool-role :class:`Message`
|
||||
containing all output contents and assigns it to ``output.messages``.
|
||||
When ``output.autoSend`` is true (default), emits the rendered string
|
||||
via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
|
||||
appends an Assistant-role :class:`Message` with the same contents to
|
||||
``System.conversations.{id}.messages``.
|
||||
- On error returned by the handler (``is_error=True``): assigns
|
||||
``"Error: <message>"`` to ``output.result`` and completes normally
|
||||
(parity with .NET ``AssignErrorAsync``).
|
||||
|
||||
.. note::
|
||||
|
||||
``output.messages`` receives a SINGLE Tool-role :class:`Message`
|
||||
(containing the full tool output as ``contents``), unlike
|
||||
:class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
|
||||
writes a list of two messages (assistant call + tool result). This
|
||||
matches the .NET ``InvokeMcpToolExecutor`` output contract.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
*,
|
||||
id: str | None = None,
|
||||
mcp_tool_handler: MCPToolHandler,
|
||||
) -> None:
|
||||
"""Create an MCP tool action executor.
|
||||
|
||||
Args:
|
||||
action_def: Parsed ``InvokeMcpTool`` YAML dict.
|
||||
id: Optional executor id (defaults to action id or generated).
|
||||
mcp_tool_handler: Handler used to dispatch MCP tool calls.
|
||||
Required: the builder enforces presence at workflow-build
|
||||
time.
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
|
||||
# ----- Main handler --------------------------------------------------------
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Execute the MCP tool action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
server_url = self._get_server_url(state)
|
||||
tool_name = self._get_tool_name(state)
|
||||
server_label = self._get_server_label(state)
|
||||
arguments = self._get_arguments(state)
|
||||
headers = self._get_headers(state)
|
||||
connection_name = self._get_connection_name(state)
|
||||
require_approval = self._get_require_approval(state)
|
||||
auto_send = self._get_auto_send(state)
|
||||
conversation_id_expr = self._action_def.get("conversationId")
|
||||
output_messages_path = _get_output_path(self._action_def, "messages")
|
||||
output_result_path = _get_output_path(self._action_def, "result")
|
||||
|
||||
if require_approval:
|
||||
request_id = str(uuid.uuid4())
|
||||
approval_state = _MCPToolApprovalState(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
connection_name=connection_name,
|
||||
headers_def=self._action_def.get("headers"),
|
||||
auto_send=auto_send,
|
||||
conversation_id_expr=conversation_id_expr if isinstance(conversation_id_expr, str) else None,
|
||||
output_messages_path=output_messages_path,
|
||||
output_result_path=output_result_path,
|
||||
)
|
||||
ctx.state.set(self._approval_key(), approval_state)
|
||||
|
||||
request = MCPToolApprovalRequest(
|
||||
request_id=request_id,
|
||||
tool_name=tool_name,
|
||||
server_url=server_url,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
header_names=sorted(headers.keys()),
|
||||
)
|
||||
logger.info(
|
||||
"%s: requesting approval for MCP tool '%s' on '%s'",
|
||||
self.__class__.__name__,
|
||||
tool_name,
|
||||
server_url,
|
||||
)
|
||||
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
||||
# Workflow yields here — resume in handle_approval_response.
|
||||
return
|
||||
|
||||
# No approval required - invoke directly.
|
||||
invocation = MCPToolInvocation(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
headers=headers,
|
||||
connection_name=connection_name,
|
||||
)
|
||||
result = await self._invoke_with_narrow_catch(invocation)
|
||||
await self._process_result(
|
||||
ctx=ctx,
|
||||
state=state,
|
||||
result=result,
|
||||
auto_send=auto_send,
|
||||
conversation_id_expr=conversation_id_expr if isinstance(conversation_id_expr, str) else None,
|
||||
output_messages_path=output_messages_path,
|
||||
output_result_path=output_result_path,
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
# ----- Approval response handler ------------------------------------------
|
||||
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self,
|
||||
original_request: MCPToolApprovalRequest,
|
||||
response: ToolApprovalResponse,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Resume after the workflow yielded for an approval request."""
|
||||
state = self._get_state(ctx.state)
|
||||
approval_key = self._approval_key()
|
||||
|
||||
try:
|
||||
approval_state: _MCPToolApprovalState = ctx.state.get(approval_key)
|
||||
except KeyError:
|
||||
logger.error("%s: approval state missing for executor '%s'", self.__class__.__name__, self.id)
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
try:
|
||||
ctx.state.delete(approval_key)
|
||||
except KeyError:
|
||||
logger.warning("%s: approval state already deleted for '%s'", self.__class__.__name__, self.id)
|
||||
|
||||
if not response.approved:
|
||||
logger.info(
|
||||
"%s: MCP tool '%s' rejected: %s",
|
||||
self.__class__.__name__,
|
||||
approval_state.tool_name,
|
||||
response.reason,
|
||||
)
|
||||
self._assign_error(
|
||||
state, approval_state.output_result_path, "MCP tool invocation was not approved by user."
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
# Approved — re-evaluate headers (not stored at approval time for security).
|
||||
headers = self._evaluate_headers(state, approval_state.headers_def)
|
||||
|
||||
invocation = MCPToolInvocation(
|
||||
server_url=approval_state.server_url,
|
||||
tool_name=approval_state.tool_name,
|
||||
server_label=approval_state.server_label,
|
||||
arguments=approval_state.arguments,
|
||||
headers=headers,
|
||||
connection_name=approval_state.connection_name,
|
||||
)
|
||||
result = await self._invoke_with_narrow_catch(invocation)
|
||||
await self._process_result(
|
||||
ctx=ctx,
|
||||
state=state,
|
||||
result=result,
|
||||
auto_send=approval_state.auto_send,
|
||||
conversation_id_expr=approval_state.conversation_id_expr,
|
||||
output_messages_path=approval_state.output_messages_path,
|
||||
output_result_path=approval_state.output_result_path,
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
# ----- Field resolution ----------------------------------------------------
|
||||
|
||||
def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("serverUrl")
|
||||
if raw is None:
|
||||
raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("toolName")
|
||||
if raw is None:
|
||||
raise ValueError("InvokeMcpTool requires a 'toolName' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
raw = self._action_def.get("serverLabel")
|
||||
if raw is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
|
||||
"""Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
|
||||
raw = self._action_def.get("arguments")
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, Mapping) or not raw:
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
result[key] = state.eval_if_expression(value)
|
||||
return result
|
||||
|
||||
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
|
||||
return self._evaluate_headers(state, self._action_def.get("headers"))
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
|
||||
"""Evaluate the ``headers`` map. Empty string values are skipped."""
|
||||
if not isinstance(headers_def, Mapping) or not headers_def:
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
if evaluated is None:
|
||||
continue
|
||||
text = str(evaluated)
|
||||
if not text:
|
||||
continue
|
||||
result[key] = text
|
||||
return result
|
||||
|
||||
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
connection = self._action_def.get("connection")
|
||||
if not isinstance(connection, Mapping):
|
||||
return None
|
||||
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
|
||||
if name_expr is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(name_expr)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
|
||||
raw = self._action_def.get("requireApproval")
|
||||
if raw is None:
|
||||
return False
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if isinstance(evaluated, bool):
|
||||
return evaluated
|
||||
if isinstance(evaluated, str):
|
||||
return evaluated.strip().lower() in {"true", "1", "yes"}
|
||||
return bool(evaluated)
|
||||
|
||||
def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
|
||||
output: Any = self._action_def.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
return True
|
||||
raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
|
||||
if raw is None:
|
||||
return True
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if isinstance(evaluated, bool):
|
||||
return evaluated
|
||||
if isinstance(evaluated, str):
|
||||
return evaluated.strip().lower() in {"true", "1", "yes"}
|
||||
return bool(evaluated)
|
||||
|
||||
# ----- Invocation + error handling ----------------------------------------
|
||||
|
||||
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke the handler with a narrow exception catch.
|
||||
|
||||
Only known transport / tool exceptions are normalised to an error
|
||||
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
|
||||
propagate so they fail loudly.
|
||||
|
||||
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
|
||||
so it is not caught here and propagates unchanged for workflow
|
||||
cancellation.
|
||||
"""
|
||||
try:
|
||||
return await self._mcp_tool_handler.invoke_tool(invocation)
|
||||
except ToolExecutionException as exc:
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ImportError: # pragma: no cover - mcp is a hard dep
|
||||
raise
|
||||
if isinstance(exc, McpError):
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
raise
|
||||
|
||||
# ----- Result handling -----------------------------------------------------
|
||||
|
||||
async def _process_result(
|
||||
self,
|
||||
*,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
state: DeclarativeWorkflowState,
|
||||
result: MCPToolResult,
|
||||
auto_send: bool,
|
||||
conversation_id_expr: str | None,
|
||||
output_messages_path: str | None,
|
||||
output_result_path: str | None,
|
||||
) -> None:
|
||||
"""Apply ``result`` to workflow state per the configured output paths."""
|
||||
if result.is_error:
|
||||
# Error path mirrors .NET ``AssignErrorAsync`` — only the result
|
||||
# path is touched; messages / autoSend / conversation are not.
|
||||
self._assign_error(
|
||||
state,
|
||||
output_result_path,
|
||||
result.error_message or "MCP tool invocation failed.",
|
||||
)
|
||||
return
|
||||
|
||||
parsed_results = _parse_outputs(result.outputs)
|
||||
if output_result_path is not None and parsed_results:
|
||||
state.set(output_result_path, parsed_results)
|
||||
|
||||
# Single Tool-role message (matches .NET line 178 contract). Differs
|
||||
# from InvokeFunctionTool's two-message [assistant call, tool result]
|
||||
# convention.
|
||||
tool_message = Message(role="tool", contents=list(result.outputs))
|
||||
if output_messages_path is not None:
|
||||
state.set(output_messages_path, tool_message)
|
||||
|
||||
if auto_send and parsed_results:
|
||||
await ctx.yield_output(_format_outputs_for_send(parsed_results))
|
||||
|
||||
if conversation_id_expr:
|
||||
messages_path = _get_messages_path(state, conversation_id_expr)
|
||||
if messages_path is not None:
|
||||
# Mirrors .NET: conversation gets ASSISTANT-role message with
|
||||
# the same outputs (so chat history reads it as the agent's
|
||||
# contribution).
|
||||
assistant_message = Message(role="assistant", contents=list(result.outputs))
|
||||
state.append(messages_path, assistant_message)
|
||||
|
||||
@staticmethod
|
||||
def _assign_error(
|
||||
state: DeclarativeWorkflowState,
|
||||
output_result_path: str | None,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
"""Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
|
||||
if output_result_path is None:
|
||||
return
|
||||
state.set(output_result_path, f"Error: {error_message}")
|
||||
|
||||
def _approval_key(self) -> str:
|
||||
return f"{_MCP_APPROVAL_STATE_KEY}_{self.id}"
|
||||
|
||||
|
||||
def _parse_outputs(outputs: list[Content]) -> list[Any]:
|
||||
"""Parse :class:`Content` outputs into Python values for ``output.result``.
|
||||
|
||||
Mirrors .NET ``AssignResultAsync``:
|
||||
|
||||
- ``TextContent`` → JSON-parse text; on failure use the raw text.
|
||||
- ``DataContent`` / ``UriContent`` → ``content.uri``.
|
||||
- Other content kinds → ``str(content)``.
|
||||
"""
|
||||
parsed: list[Any] = []
|
||||
for content in outputs:
|
||||
kind = getattr(content, "type", None)
|
||||
if kind == "text":
|
||||
text_value = getattr(content, "text", None)
|
||||
text_str = "" if text_value is None else str(text_value)
|
||||
try:
|
||||
parsed.append(json.loads(text_str))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed.append(text_str)
|
||||
continue
|
||||
if kind in ("data", "uri"):
|
||||
uri_value = getattr(content, "uri", None)
|
||||
parsed.append("" if uri_value is None else str(uri_value))
|
||||
continue
|
||||
parsed.append(str(content))
|
||||
return parsed
|
||||
|
||||
|
||||
MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"InvokeMcpTool": InvokeMcpToolActionExecutor,
|
||||
}
|
||||
@@ -24,18 +24,17 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
Workflow,
|
||||
)
|
||||
from agent_framework.exceptions import WorkflowException
|
||||
|
||||
from .._loader import AgentFactory
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._http_handler import HttpRequestHandler
|
||||
from ._mcp_handler import MCPToolHandler
|
||||
|
||||
logger = logging.getLogger("agent_framework.declarative")
|
||||
|
||||
|
||||
class DeclarativeWorkflowError(WorkflowException):
|
||||
"""Exception raised for errors in declarative workflow processing."""
|
||||
|
||||
pass
|
||||
__all__ = ["WorkflowFactory"]
|
||||
|
||||
|
||||
class WorkflowFactory:
|
||||
@@ -92,6 +91,8 @@ class WorkflowFactory:
|
||||
env_file: str | None = None,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
) -> None:
|
||||
"""Initialize the workflow factory.
|
||||
|
||||
@@ -105,6 +106,19 @@ class WorkflowFactory:
|
||||
max_iterations: Optional maximum runner supersteps. Overrides the YAML ``maxTurns``
|
||||
field and the core default (100). Workflows with ``GotoAction`` loops (e.g.
|
||||
DeepResearch) typically need a higher value.
|
||||
http_request_handler: Optional handler used to dispatch HTTP requests for
|
||||
``HttpRequestAction``. Required if the workflow contains any
|
||||
``HttpRequestAction``; build will fail with :class:`DeclarativeWorkflowError`
|
||||
otherwise. Use :class:`agent_framework.declarative.DefaultHttpRequestHandler`
|
||||
for a no-policy ``httpx``-based default, or supply your own implementation
|
||||
to enforce SSRF guards, allowlisting, or auth resolution.
|
||||
mcp_tool_handler: Optional handler used to dispatch MCP tool calls for
|
||||
``InvokeMcpTool``. Required if the workflow contains any
|
||||
``InvokeMcpTool``; build will fail with :class:`DeclarativeWorkflowError`
|
||||
otherwise. Use :class:`agent_framework.declarative.DefaultMCPToolHandler`
|
||||
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
|
||||
or supply your own implementation to enforce SSRF guards, allowlisting,
|
||||
or auth/connection resolution.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -144,6 +158,8 @@ class WorkflowFactory:
|
||||
self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
self._max_iterations = max_iterations
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
|
||||
def create_workflow_from_yaml_path(
|
||||
self,
|
||||
@@ -387,6 +403,8 @@ class WorkflowFactory:
|
||||
tools=self._tools,
|
||||
checkpoint_storage=self._checkpoint_storage,
|
||||
max_iterations=self._max_iterations,
|
||||
http_request_handler=self._http_request_handler,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
)
|
||||
workflow = graph_builder.build()
|
||||
except ValueError as e:
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""HTTP request handler abstraction for declarative workflows.
|
||||
|
||||
Mirrors the .NET ``IHttpRequestHandler`` / ``DefaultHttpRequestHandler`` pair from
|
||||
``Microsoft.Agents.AI.Workflows.Declarative``. Provides:
|
||||
|
||||
- :class:`HttpRequestInfo` — request input data passed from the executor.
|
||||
- :class:`HttpRequestResult` — response data returned to the executor.
|
||||
- :class:`HttpRequestHandler` — :class:`typing.Protocol` callers implement to plug
|
||||
in custom transports (e.g. with allowlisting, mTLS, retries, etc.).
|
||||
- :class:`DefaultHttpRequestHandler` — production-grade default backed by
|
||||
``httpx.AsyncClient``.
|
||||
|
||||
Security note: :class:`DefaultHttpRequestHandler` performs **no** URL filtering
|
||||
or SSRF protection. Production deployments should supply a custom handler that
|
||||
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
|
||||
.NET design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
__all__ = [
|
||||
"DefaultHttpRequestHandler",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpRequestInfo:
|
||||
"""Description of an HTTP request to be dispatched by a :class:`HttpRequestHandler`.
|
||||
|
||||
Mirrors the .NET ``HttpRequestInfo`` record. Field semantics:
|
||||
|
||||
- ``method``: HTTP method (``GET``, ``POST``, etc.). Already upper-cased by the executor.
|
||||
- ``url``: Absolute URL. Already evaluated from the YAML expression.
|
||||
- ``headers``: Single-value header map (case-insensitive keys per HTTP semantics
|
||||
but stored as authored). Empty values are skipped by the executor.
|
||||
- ``query_parameters``: String key/value pairs appended to the URL.
|
||||
- ``body``: Request body bytes/text, or ``None`` for no body.
|
||||
- ``body_content_type``: Content type to send (e.g. ``application/json``).
|
||||
Ignored when ``body`` is ``None``.
|
||||
- ``timeout_ms``: Per-request timeout in milliseconds. ``None`` => use the
|
||||
handler's default.
|
||||
- ``connection_name``: Optional Foundry connection name for handlers that
|
||||
resolve auth/credentials by connection.
|
||||
"""
|
||||
|
||||
method: str
|
||||
url: str
|
||||
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
query_parameters: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
body: str | None = None
|
||||
body_content_type: str | None = None
|
||||
timeout_ms: int | None = None
|
||||
connection_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpRequestResult:
|
||||
"""Response returned by a :class:`HttpRequestHandler`.
|
||||
|
||||
Mirrors the .NET ``HttpRequestResult`` record. ``headers`` preserves
|
||||
multi-value response headers (e.g. multiple ``Set-Cookie`` headers) as a
|
||||
``dict[str, list[str]]``. The executor folds duplicates into a single
|
||||
comma-joined string only at the point it assigns ``responseHeaders`` to
|
||||
workflow state.
|
||||
|
||||
Header keys are normalized to lowercase so that lookups are consistent
|
||||
regardless of the server's transmitted casing (HTTP headers are
|
||||
case-insensitive per RFC 7230 §3.2). Custom :class:`HttpRequestHandler`
|
||||
implementations should follow the same convention.
|
||||
"""
|
||||
|
||||
status_code: int
|
||||
is_success_status_code: bool
|
||||
body: str
|
||||
headers: dict[str, list[str]] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpRequestHandler(Protocol):
|
||||
"""Protocol for HTTP request handlers used by ``HttpRequestAction``.
|
||||
|
||||
Implementations must be safe to call concurrently from multiple workflow
|
||||
runs. Implementations are responsible for any URL allowlisting, SSRF
|
||||
guards, retry policies, auth resolution, and other policies that the
|
||||
workflow author wants applied.
|
||||
"""
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
"""Dispatch ``info`` and return the response result.
|
||||
|
||||
Args:
|
||||
info: Description of the request to send.
|
||||
|
||||
Returns:
|
||||
The response. Implementations should NOT raise on non-2xx status
|
||||
codes; instead, set ``is_success_status_code`` accordingly. They
|
||||
SHOULD raise on transport-level failures (connection refused,
|
||||
DNS errors, timeouts).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
ClientProvider = Callable[[HttpRequestInfo], Awaitable["httpx.AsyncClient | None"]]
|
||||
|
||||
|
||||
class DefaultHttpRequestHandler:
|
||||
"""Default :class:`HttpRequestHandler` backed by :class:`httpx.AsyncClient`.
|
||||
|
||||
Construction modes:
|
||||
|
||||
1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
|
||||
on first ``send()``. Closed by :meth:`aclose`.
|
||||
2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
|
||||
Not closed by :meth:`aclose`.
|
||||
3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
|
||||
lookup (parity with .NET's ``httpClientProvider`` callback). The
|
||||
provider may return ``None`` to fall back to the owned/default client.
|
||||
|
||||
.. warning::
|
||||
|
||||
This handler performs **no** URL filtering or SSRF protection. Wrap or
|
||||
replace it with a custom handler in production.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
client_provider: ClientProvider | None = None,
|
||||
) -> None:
|
||||
self._owned_client: httpx.AsyncClient | None = None
|
||||
self._caller_client = client
|
||||
self._client_provider = client_provider
|
||||
# Guards lazy creation of ``_owned_client`` against concurrent first
|
||||
# ``send()`` calls leaking duplicate clients.
|
||||
self._owned_client_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
"""Dispatch the request and return the parsed result."""
|
||||
if not info.url:
|
||||
raise ValueError("HttpRequestInfo.url must be a non-empty string.")
|
||||
if not info.method:
|
||||
raise ValueError("HttpRequestInfo.method must be a non-empty string.")
|
||||
|
||||
client = await self._resolve_client(info)
|
||||
|
||||
timeout: httpx.Timeout | object
|
||||
if info.timeout_ms is not None and info.timeout_ms > 0:
|
||||
timeout = httpx.Timeout(info.timeout_ms / 1000.0)
|
||||
else:
|
||||
timeout = httpx.USE_CLIENT_DEFAULT
|
||||
|
||||
headers = dict(info.headers)
|
||||
content: bytes | str | None = None
|
||||
if info.body is not None:
|
||||
content = info.body
|
||||
if not _has_header(headers, "content-type"):
|
||||
# Match .NET DefaultHttpRequestHandler: when a body is sent
|
||||
# without an explicit content type, default to ``text/plain``
|
||||
# so the request is interpretable by servers and direct
|
||||
# callers (not just the YAML executor) get sensible defaults.
|
||||
headers["Content-Type"] = info.body_content_type or "text/plain"
|
||||
|
||||
params: Mapping[str, str] | None = info.query_parameters or None
|
||||
|
||||
response = await client.request(
|
||||
method=info.method,
|
||||
url=info.url,
|
||||
params=params,
|
||||
headers=headers or None,
|
||||
content=content,
|
||||
timeout=timeout, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
|
||||
# Normalize names to lowercase so lookups are consistent and case
|
||||
# variations from the transport do not create duplicate logical keys
|
||||
# (HTTP headers are case-insensitive per RFC 7230 §3.2).
|
||||
result_headers: dict[str, list[str]] = {}
|
||||
for key, value in response.headers.multi_items():
|
||||
result_headers.setdefault(key.lower(), []).append(value)
|
||||
|
||||
body_text = response.text
|
||||
|
||||
return HttpRequestResult(
|
||||
status_code=response.status_code,
|
||||
is_success_status_code=200 <= response.status_code < 300,
|
||||
body=body_text,
|
||||
headers=result_headers,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Release the owned client, if any. Caller-owned clients are NOT closed."""
|
||||
if self._owned_client is not None:
|
||||
await self._owned_client.aclose()
|
||||
self._owned_client = None
|
||||
|
||||
async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient:
|
||||
"""Pick a client for this request: provider → caller → lazily-owned."""
|
||||
if self._client_provider is not None:
|
||||
provided = await self._client_provider(info)
|
||||
if provided is not None:
|
||||
return provided
|
||||
if self._caller_client is not None:
|
||||
return self._caller_client
|
||||
if self._owned_client is None:
|
||||
# Double-checked locking under asyncio.Lock so concurrent first
|
||||
# callers don't each create a fresh httpx.AsyncClient and orphan
|
||||
# one of them.
|
||||
async with self._owned_client_lock:
|
||||
if self._owned_client is None:
|
||||
self._owned_client = httpx.AsyncClient()
|
||||
return self._owned_client
|
||||
|
||||
async def __aenter__(self) -> DefaultHttpRequestHandler:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.aclose()
|
||||
|
||||
|
||||
def _has_header(headers: Mapping[str, str], name: str) -> bool:
|
||||
"""Case-insensitive header presence check."""
|
||||
needle = name.lower()
|
||||
return any(key.lower() == needle for key in headers)
|
||||
@@ -0,0 +1,494 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""MCP tool handler abstraction for declarative workflows.
|
||||
|
||||
Mirrors the .NET ``IMcpToolHandler`` / ``DefaultMcpToolHandler`` pair from
|
||||
``Microsoft.Agents.AI.Workflows.Declarative.Mcp``. Provides:
|
||||
|
||||
- :class:`MCPToolInvocation` — request input data passed from the executor.
|
||||
- :class:`MCPToolResult` — response data returned to the executor.
|
||||
- :class:`MCPToolHandler` — :class:`typing.Protocol` callers implement to plug
|
||||
in custom transports (e.g. with allowlisting, Foundry connection resolution,
|
||||
per-server auth, etc.).
|
||||
- :class:`DefaultMCPToolHandler` — production-grade default backed by
|
||||
:class:`agent_framework.MCPStreamableHTTPTool`.
|
||||
|
||||
Security note: :class:`DefaultMCPToolHandler` performs **no** URL filtering or
|
||||
SSRF protection. Production deployments should supply a custom handler that
|
||||
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
|
||||
.NET design.
|
||||
|
||||
Prompt-injection note: MCP tool outputs flow back into agent conversations
|
||||
(via ``conversationId`` and Tool-role messages emitted by the executor) so
|
||||
they share the same risk surface as ``HttpRequestAction``. Workflow authors
|
||||
must trust the MCP server they invoke.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Content
|
||||
|
||||
__all__ = [
|
||||
"ClientProvider",
|
||||
"DefaultMCPToolHandler",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CACHE_MAX_SIZE = 32
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolInvocation:
|
||||
"""Description of an MCP tool call to be dispatched by a :class:`MCPToolHandler`.
|
||||
|
||||
Mirrors the input parameters of the .NET ``IMcpToolHandler.InvokeToolAsync``
|
||||
method. Field semantics:
|
||||
|
||||
- ``server_url``: Absolute URL of the MCP server. Already evaluated from
|
||||
the YAML expression.
|
||||
- ``server_label``: Optional human-readable label used for diagnostics
|
||||
and as the underlying ``MCPStreamableHTTPTool`` name.
|
||||
- ``tool_name``: Name of the tool to invoke on the MCP server.
|
||||
- ``arguments``: Tool arguments. Already evaluated; values may be any
|
||||
JSON-serialisable Python object (str, int, bool, dict, list, None).
|
||||
- ``headers``: Outbound HTTP headers (e.g. authentication). Empty values
|
||||
are skipped by the executor before construction.
|
||||
- ``connection_name``: Optional Foundry connection name forwarded for
|
||||
handlers that resolve auth/credentials by connection. The default
|
||||
handler does not consume this field.
|
||||
"""
|
||||
|
||||
server_url: str
|
||||
tool_name: str
|
||||
server_label: str | None = None
|
||||
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
connection_name: str | None = None
|
||||
|
||||
|
||||
def _empty_outputs() -> list[Any]:
|
||||
"""Default factory for ``MCPToolResult.outputs``.
|
||||
|
||||
Typed as ``list[Any]`` here to keep the dataclass field's runtime
|
||||
factory simple; the public type on :class:`MCPToolResult` is
|
||||
``list[Content]``.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolResult:
|
||||
"""Response returned by an :class:`MCPToolHandler`.
|
||||
|
||||
Mirrors the .NET ``McpServerToolResultContent`` shape. ``outputs`` is a
|
||||
list of :class:`agent_framework.Content` items as parsed by the MCP
|
||||
transport (TextContent / DataContent / UriContent / etc.).
|
||||
|
||||
On error, ``is_error`` is ``True``, ``error_message`` carries a human
|
||||
readable description, and ``outputs`` typically contains a single
|
||||
``Content.from_text("Error: ...")`` entry for downstream display.
|
||||
"""
|
||||
|
||||
outputs: list[Content] = field(default_factory=_empty_outputs)
|
||||
is_error: bool = False
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MCPToolHandler(Protocol):
|
||||
"""Protocol for MCP tool handlers used by ``InvokeMcpTool``.
|
||||
|
||||
Mirrors :class:`HttpRequestHandler` — declares ONLY the invocation method.
|
||||
Lifecycle methods (``aclose`` / ``__aenter__`` / ``__aexit__``) are NOT
|
||||
part of the Protocol; concrete implementations may add them as
|
||||
appropriate.
|
||||
|
||||
Implementations must be safe to call concurrently from multiple workflow
|
||||
runs. Implementations are responsible for any URL allowlisting, SSRF
|
||||
guards, retry policies, auth resolution, and other policies the workflow
|
||||
author wants applied.
|
||||
"""
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Dispatch ``invocation`` and return the result.
|
||||
|
||||
Args:
|
||||
invocation: Description of the MCP tool call to perform.
|
||||
|
||||
Returns:
|
||||
The :class:`MCPToolResult` carrying the parsed outputs (or an
|
||||
error flag if the tool raised). Implementations SHOULD return a
|
||||
result with ``is_error=True`` rather than raising for transport
|
||||
or tool-level failures, so the workflow can store the message in
|
||||
``output.result`` (matching .NET ``AssignErrorAsync`` behaviour).
|
||||
They MAY raise on unexpected programming errors — these will be
|
||||
propagated unchanged by the executor so they fail loudly.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Internal record stored in the LRU cache."""
|
||||
|
||||
tool: Any # MCPStreamableHTTPTool — typed Any to avoid import at module load
|
||||
owned_httpx_client: httpx.AsyncClient | None
|
||||
|
||||
|
||||
class DefaultMCPToolHandler:
|
||||
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
|
||||
|
||||
Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per
|
||||
``(server_url, server_label, connection_name, headers_hash)`` in a
|
||||
bounded LRU. The cache prevents re-establishing an MCP session for every
|
||||
invocation while ensuring different header sets (auth tokens) cannot
|
||||
share a session — matches the .NET design intent while bounding
|
||||
cardinality. ``server_label`` and ``connection_name`` participate in
|
||||
the key so that callers using ``client_provider`` to dispatch on those
|
||||
fields receive a fresh client per logical connection (see below).
|
||||
Header *names* are lower-cased inside the hash payload only — the
|
||||
headers passed on the wire keep the caller's original casing — so two
|
||||
YAML actions that spell ``Authorization`` differently still share a
|
||||
cache entry.
|
||||
|
||||
Construction modes:
|
||||
|
||||
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
|
||||
instances created lazily per cache entry. Closed by :meth:`aclose`.
|
||||
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client
|
||||
lookup (parity with .NET ``httpClientProvider`` callback). The
|
||||
callback receives the full :class:`MCPToolInvocation` so it can
|
||||
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
|
||||
Returning ``None`` falls back to an internally-created client. Caller
|
||||
supplied clients are NOT closed by :meth:`aclose`.
|
||||
|
||||
.. warning::
|
||||
|
||||
This handler performs **no** URL filtering or SSRF protection. Wrap
|
||||
or replace it with a custom handler in production deployments.
|
||||
|
||||
Args:
|
||||
client_provider: Optional per-server ``httpx.AsyncClient`` provider.
|
||||
cache_max_size: Maximum number of cached MCP clients. When exceeded,
|
||||
the least-recently-used entry is evicted and its client closed
|
||||
(only owned clients are closed; caller-supplied ones are not).
|
||||
Defaults to ``32``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_provider: ClientProvider | None = None,
|
||||
cache_max_size: int = _DEFAULT_CACHE_MAX_SIZE,
|
||||
) -> None:
|
||||
if cache_max_size <= 0:
|
||||
raise ValueError(f"cache_max_size must be positive, got {cache_max_size}")
|
||||
self._client_provider = client_provider
|
||||
self._cache_max_size = cache_max_size
|
||||
self._cache: OrderedDict[tuple[str, str, str, str], _CacheEntry] = OrderedDict()
|
||||
# Outer lock guards the cache + in-flight-future map only — never
|
||||
# held across network I/O.
|
||||
self._cache_lock = asyncio.Lock()
|
||||
# Per-key in-flight futures: while one task is connecting, other
|
||||
# tasks awaiting the same key will await the same future and share
|
||||
# the resulting cache entry.
|
||||
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
|
||||
# Set by ``aclose`` to prevent post-close cache insertions and to
|
||||
# reject new ``invoke_tool`` calls. Once set, never cleared.
|
||||
self._closed = False
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
try:
|
||||
entry = await self._get_or_create_entry(invocation)
|
||||
except Exception as exc:
|
||||
# Connect / cache lookup failures surface as tool errors so the
|
||||
# workflow can store them at output.result without crashing.
|
||||
logger.warning(
|
||||
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
|
||||
invocation.server_url,
|
||||
invocation.tool_name,
|
||||
exc,
|
||||
)
|
||||
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
||||
except ToolExecutionException as exc:
|
||||
logger.info(
|
||||
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
|
||||
invocation.tool_name,
|
||||
invocation.server_url,
|
||||
)
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Be defensive about MCP errors that may bubble up without being
|
||||
# wrapped in ToolExecutionException by custom parsers.
|
||||
try:
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
|
||||
raise
|
||||
if isinstance(exc, McpError):
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
raise
|
||||
|
||||
# Defensive normalisation: call_tool is typed ``str | list[Content]``.
|
||||
# Default parser returns list, but custom parse_tool_results may return str.
|
||||
if isinstance(raw, str):
|
||||
outputs: list[Content] = [Content.from_text(raw)]
|
||||
else:
|
||||
outputs = list(raw)
|
||||
return MCPToolResult(outputs=outputs)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all cached MCP clients and the owned httpx clients.
|
||||
|
||||
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
|
||||
``client_provider`` callback) are NOT closed.
|
||||
|
||||
Idempotent — a second call returns immediately. Drains any in-flight
|
||||
``_create_entry`` tasks before returning so their resources are
|
||||
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
|
||||
:meth:`_get_or_create_entry`, close their own entry, and resolve
|
||||
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
|
||||
"""
|
||||
async with self._cache_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
entries = list(self._cache.values())
|
||||
self._cache.clear()
|
||||
inflight_futures = list(self._inflight.values())
|
||||
|
||||
# Wait for in-flight creations to finish their self-cleanup. Each
|
||||
# in-flight task self-closes its entry under the closed-flag branch
|
||||
# in phase 3 and resolves its future with ``RuntimeError``; we
|
||||
# swallow it here because the failure is expected at shutdown.
|
||||
for fut in inflight_futures:
|
||||
try:
|
||||
await fut
|
||||
except BaseException:
|
||||
logger.debug("DefaultMCPToolHandler: in-flight future raised during aclose", exc_info=True)
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
await self._close_entry(entry)
|
||||
|
||||
async def __aenter__(self) -> DefaultMCPToolHandler:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.aclose()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_or_create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
||||
"""Look up (or create) the cached MCP client for this invocation."""
|
||||
key = self._cache_key(
|
||||
invocation.server_url,
|
||||
invocation.server_label,
|
||||
invocation.connection_name,
|
||||
invocation.headers,
|
||||
)
|
||||
|
||||
# Phase 1: check the cache and either claim creation or wait for an
|
||||
# already in-flight creation.
|
||||
creating = False
|
||||
async with self._cache_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("DefaultMCPToolHandler is closed")
|
||||
existing = self._cache.get(key)
|
||||
if existing is not None:
|
||||
self._cache.move_to_end(key)
|
||||
return existing
|
||||
inflight = self._inflight.get(key)
|
||||
if inflight is None:
|
||||
inflight = asyncio.get_running_loop().create_future()
|
||||
self._inflight[key] = inflight
|
||||
creating = True
|
||||
|
||||
if not creating:
|
||||
return await inflight
|
||||
|
||||
# Phase 2: we own creation. Build the entry outside the lock.
|
||||
try:
|
||||
entry = await self._create_entry(invocation)
|
||||
except BaseException as exc:
|
||||
async with self._cache_lock:
|
||||
self._inflight.pop(key, None)
|
||||
if not inflight.done():
|
||||
inflight.set_exception(exc if isinstance(exc, BaseException) else RuntimeError(str(exc)))
|
||||
# Mark the exception retrieved to suppress noisy "Future exception
|
||||
# was never retrieved" warnings when there are no other awaiters
|
||||
# (other awaiters still see the exception through their ``await``).
|
||||
inflight.exception()
|
||||
raise
|
||||
|
||||
# Phase 3: insert with LRU eviction; resolve the in-flight future.
|
||||
# If ``aclose`` ran while we were connecting, ``_closed`` is now
|
||||
# True; don't insert into the cache (it has been drained), close
|
||||
# the just-built entry, and surface the closed-handler error to
|
||||
# all awaiters of the future.
|
||||
evicted: _CacheEntry | None = None
|
||||
duplicate: _CacheEntry | None = None
|
||||
handler_closed = False
|
||||
async with self._cache_lock:
|
||||
self._inflight.pop(key, None)
|
||||
if self._closed:
|
||||
handler_closed = True
|
||||
else:
|
||||
existing = self._cache.get(key)
|
||||
if existing is not None:
|
||||
# Another writer beat us; prefer the existing entry and
|
||||
# discard ours after the lock is released.
|
||||
self._cache.move_to_end(key)
|
||||
duplicate = entry
|
||||
entry = existing
|
||||
else:
|
||||
self._cache[key] = entry
|
||||
self._cache.move_to_end(key)
|
||||
if len(self._cache) > self._cache_max_size:
|
||||
_evicted_key, evicted = self._cache.popitem(last=False)
|
||||
if not inflight.done():
|
||||
inflight.set_result(entry)
|
||||
|
||||
if handler_closed:
|
||||
# Close our orphaned entry; resolve the future with a clear
|
||||
# error so the caller (and any other awaiters) surface a
|
||||
# consistent "handler is closed" failure rather than receiving
|
||||
# an entry we are about to close behind their back.
|
||||
await self._close_entry(entry)
|
||||
err = RuntimeError("DefaultMCPToolHandler is closed")
|
||||
if not inflight.done():
|
||||
inflight.set_exception(err)
|
||||
inflight.exception()
|
||||
raise err
|
||||
if duplicate is not None:
|
||||
await self._close_entry(duplicate)
|
||||
if evicted is not None:
|
||||
await self._close_entry(evicted)
|
||||
return entry
|
||||
|
||||
async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
||||
"""Construct (and connect) a fresh MCP client for ``invocation``."""
|
||||
from agent_framework import MCPStreamableHTTPTool
|
||||
|
||||
provided_client: httpx.AsyncClient | None = None
|
||||
if self._client_provider is not None:
|
||||
provided_client = await self._client_provider(invocation)
|
||||
# Capture headers for this cache entry so the header_provider closure
|
||||
# always returns the same set, regardless of the runtime kwargs.
|
||||
captured_headers = dict(invocation.headers)
|
||||
|
||||
def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
return captured_headers
|
||||
|
||||
tool: Any = MCPStreamableHTTPTool(
|
||||
name=invocation.server_label or "McpClient",
|
||||
url=invocation.server_url,
|
||||
load_prompts=False,
|
||||
http_client=provided_client,
|
||||
header_provider=_header_provider if captured_headers else None,
|
||||
)
|
||||
try:
|
||||
await tool.connect()
|
||||
except BaseException:
|
||||
try:
|
||||
await tool.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True)
|
||||
raise
|
||||
|
||||
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
|
||||
# ``httpx.AsyncClient`` when no caller client was provided AND a
|
||||
# ``header_provider`` was set. We treat any client allocated this
|
||||
# way as owned (closed by the handler). When the caller supplies
|
||||
# one, we never close it.
|
||||
owned_client: httpx.AsyncClient | None = None
|
||||
if provided_client is None:
|
||||
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
|
||||
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
|
||||
|
||||
async def _close_entry(self, entry: _CacheEntry) -> None:
|
||||
"""Close the MCP tool and any owned httpx client."""
|
||||
try:
|
||||
await entry.tool.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
|
||||
if entry.owned_httpx_client is not None:
|
||||
try:
|
||||
await entry.owned_httpx_client.aclose()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(
|
||||
server_url: str,
|
||||
server_label: str | None,
|
||||
connection_name: str | None,
|
||||
headers: dict[str, str] | None,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Build an order-independent cache key for the invocation identity.
|
||||
|
||||
The key includes ``server_label`` and ``connection_name`` so that
|
||||
callers using ``client_provider`` to dispatch on those fields
|
||||
receive a fresh client per logical connection (matches the
|
||||
documented dispatch contract).
|
||||
|
||||
Header *names* are lower-cased inside the hash payload only so
|
||||
that ``Authorization`` and ``authorization`` map to the same
|
||||
cache entry. Header values remain case-sensitive (per RFC 7235).
|
||||
"""
|
||||
if not headers:
|
||||
headers_hash = "0"
|
||||
else:
|
||||
normalized = sorted((k.lower(), v) for k, v in headers.items())
|
||||
payload = json.dumps(normalized, ensure_ascii=False)
|
||||
headers_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return (server_url, server_label or "", connection_name or "", headers_hash)
|
||||
@@ -23,6 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``DefaultHttpRequestHandler``.
|
||||
|
||||
These tests exercise the real handler against ``httpx.MockTransport`` (no real
|
||||
network) to cover the parts of the handler not exercisable through the executor
|
||||
stub: query-param URL composition, content-type forwarding, per-request
|
||||
timeout overrides, multi-value response header preservation, and client
|
||||
ownership semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
# These tests don't actually need PowerFx, but the rest of the suite gates on
|
||||
# Python versions and we keep behaviour consistent.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14),
|
||||
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
|
||||
)
|
||||
|
||||
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
|
||||
DefaultHttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
)
|
||||
|
||||
|
||||
def _make_handler(transport: httpx.MockTransport) -> DefaultHttpRequestHandler:
|
||||
"""Return a handler with a MockTransport-backed caller-owned client."""
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
return DefaultHttpRequestHandler(client=client)
|
||||
|
||||
|
||||
class TestRequestComposition:
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_parameters_merged_into_url(self) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(200, text="ok")
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="GET",
|
||||
url="https://api.example.test/items",
|
||||
query_parameters={"q": "alpha", "limit": "5"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
# httpx exposes the merged URL with QS appended
|
||||
assert req.url.params.get("q") == "alpha"
|
||||
assert req.url.params.get("limit") == "5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_content_type_forwarded(self) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(204)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="POST",
|
||||
url="https://api.example.test/items",
|
||||
body='{"k":"v"}',
|
||||
body_content_type="application/json",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
assert req.headers.get("content-type") == "application/json"
|
||||
assert req.content == b'{"k":"v"}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_content_type_header_not_overwritten(self) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(200, text="ok")
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="POST",
|
||||
url="https://api.example.test/items",
|
||||
headers={"Content-Type": "application/xml"}, # caller wins
|
||||
body="<x/>",
|
||||
body_content_type="application/json",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
assert req.headers.get("content-type") == "application/xml"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_without_content_type_defaults_to_text_plain(self) -> None:
|
||||
"""Match .NET DefaultHttpRequestHandler: body without explicit content type → ``text/plain``."""
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = request
|
||||
return httpx.Response(204)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="POST",
|
||||
url="https://api.example.test/items",
|
||||
body="hello",
|
||||
# No body_content_type and no Content-Type header.
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
req = captured["req"]
|
||||
assert req.headers.get("content-type") == "text/plain"
|
||||
assert req.content == b"hello"
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_request_timeout_surfaces_as_timeout_exception(self) -> None:
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.TimeoutException("simulated timeout", request=request)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
with pytest.raises(httpx.TimeoutException):
|
||||
await handler.send(
|
||||
HttpRequestInfo(
|
||||
method="GET",
|
||||
url="https://api.example.test/slow",
|
||||
timeout_ms=50,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
|
||||
class TestResponseHeaders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_value_headers_preserved(self) -> None:
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
text="ok",
|
||||
headers=[
|
||||
("Content-Type", "application/json"),
|
||||
("Set-Cookie", "a=1"),
|
||||
("Set-Cookie", "b=2"),
|
||||
],
|
||||
)
|
||||
|
||||
handler = _make_handler(httpx.MockTransport(respond))
|
||||
try:
|
||||
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
finally:
|
||||
await handler.aclose()
|
||||
|
||||
assert result.is_success_status_code
|
||||
# The handler keeps multi-value headers as list[str].
|
||||
assert result.headers.get("set-cookie") == ["a=1", "b=2"]
|
||||
assert result.headers.get("content-type") == ["application/json"]
|
||||
|
||||
|
||||
class TestClientOwnership:
|
||||
@pytest.mark.asyncio
|
||||
async def test_owned_client_is_closed_on_aclose(self) -> None:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
# Inject a MockTransport-backed client into the owned slot and verify
|
||||
# aclose() releases it. Avoids real network access.
|
||||
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
handler._owned_client = owned
|
||||
assert not owned.is_closed
|
||||
await handler.aclose()
|
||||
assert owned.is_closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_caller_owned_client_is_not_closed(self) -> None:
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
handler = DefaultHttpRequestHandler(client=client)
|
||||
await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
await handler.aclose()
|
||||
assert not client.is_closed
|
||||
await client.aclose() # cleanup
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_first_send_creates_single_owned_client(self) -> None:
|
||||
"""Concurrent first-send calls must not race-leak duplicate clients.
|
||||
|
||||
Without the lock, two concurrent calls on a fresh handler would each
|
||||
observe ``_owned_client is None`` and create their own
|
||||
``httpx.AsyncClient``, orphaning one. Verify that lazy initialization
|
||||
is serialized: all concurrent sends end up using the same client and
|
||||
``aclose()`` cleanly closes it.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Patch httpx.AsyncClient to count constructions, but only when called
|
||||
# from inside _resolve_client (no transport=) so we don't break the
|
||||
# MockTransport-backed clients used elsewhere.
|
||||
original_ctor = httpx.AsyncClient
|
||||
construction_count = 0
|
||||
|
||||
def counting_ctor(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
nonlocal construction_count
|
||||
if not args and not kwargs:
|
||||
construction_count += 1
|
||||
return original_ctor(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
return original_ctor(*args, **kwargs)
|
||||
|
||||
import agent_framework_declarative._workflows._http_handler as hh
|
||||
|
||||
hh.httpx.AsyncClient = counting_ctor # type: ignore[assignment]
|
||||
try:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
try:
|
||||
await asyncio.gather(*[
|
||||
handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) for _ in range(8)
|
||||
])
|
||||
finally:
|
||||
await handler.aclose()
|
||||
finally:
|
||||
hh.httpx.AsyncClient = original_ctor # type: ignore[assignment]
|
||||
|
||||
assert construction_count == 1, (
|
||||
f"Expected exactly 1 owned client to be lazily created but got {construction_count}"
|
||||
)
|
||||
|
||||
|
||||
class TestClientProvider:
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_provider_overrides_default(self) -> None:
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def primary(request: httpx.Request) -> httpx.Response:
|
||||
captured["transport"] = "primary"
|
||||
return httpx.Response(200, text="primary")
|
||||
|
||||
def provided(request: httpx.Request) -> httpx.Response:
|
||||
captured["transport"] = "provided"
|
||||
return httpx.Response(200, text="provided")
|
||||
|
||||
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
|
||||
provided_client = httpx.AsyncClient(transport=httpx.MockTransport(provided))
|
||||
|
||||
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient:
|
||||
return provided_client
|
||||
|
||||
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
|
||||
try:
|
||||
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
assert result.body == "provided"
|
||||
assert captured["transport"] == "provided"
|
||||
finally:
|
||||
await handler.aclose()
|
||||
await primary_client.aclose()
|
||||
await provided_client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_provider_returning_none_falls_back(self) -> None:
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def primary(request: httpx.Request) -> httpx.Response:
|
||||
captured["transport"] = "primary"
|
||||
return httpx.Response(200, text="primary")
|
||||
|
||||
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None:
|
||||
return None
|
||||
|
||||
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
|
||||
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
|
||||
try:
|
||||
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
|
||||
assert result.body == "primary"
|
||||
finally:
|
||||
await handler.aclose()
|
||||
await primary_client.aclose()
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_raises(self) -> None:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
with pytest.raises(ValueError):
|
||||
await handler.send(HttpRequestInfo(method="GET", url=""))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_method_raises(self) -> None:
|
||||
handler = DefaultHttpRequestHandler()
|
||||
with pytest.raises(ValueError):
|
||||
await handler.send(HttpRequestInfo(method="", url="https://x.test/"))
|
||||
|
||||
|
||||
class TestAsyncContextManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_closes_owned_client(self) -> None:
|
||||
async with DefaultHttpRequestHandler() as handler:
|
||||
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
|
||||
handler._owned_client = owned
|
||||
assert owned.is_closed
|
||||
@@ -0,0 +1,543 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``DefaultMCPToolHandler``.
|
||||
|
||||
These tests exercise the real handler against a fake ``MCPStreamableHTTPTool``
|
||||
(no real MCP server, no real network) to cover the parts of the handler not
|
||||
exercisable through the executor stub: cache hit/miss/eviction, concurrent
|
||||
connect via in-flight futures, header isolation across cache keys,
|
||||
string-result normalisation, ``load_prompts=False`` verification, and
|
||||
owned-vs-caller httpx close semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
from agent_framework_declarative._workflows._mcp_handler import (
|
||||
DefaultMCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14),
|
||||
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
|
||||
)
|
||||
|
||||
|
||||
class FakeTool:
|
||||
"""Stand-in for ``MCPStreamableHTTPTool``.
|
||||
|
||||
Records constructor kwargs, tracks connect/close lifecycle, and dispatches
|
||||
``call_tool`` to a per-instance handler.
|
||||
"""
|
||||
|
||||
instances: list[FakeTool] = []
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.kwargs = kwargs
|
||||
self.connect_count = 0
|
||||
self.close_count = 0
|
||||
self.connect_delay: float = 0.0
|
||||
self.connect_error: BaseException | None = None
|
||||
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
|
||||
self._httpx_client: httpx.AsyncClient | None = None
|
||||
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
|
||||
# is set, lazily allocate an owned httpx client during connect.
|
||||
FakeTool.instances.append(self)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.connect_delay:
|
||||
await asyncio.sleep(self.connect_delay)
|
||||
if self.connect_error is not None:
|
||||
raise self.connect_error
|
||||
self.connect_count += 1
|
||||
# Mimic lazy httpx allocation when no client provided AND header_provider set.
|
||||
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
|
||||
self._httpx_client = httpx.AsyncClient()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_count += 1
|
||||
|
||||
async def call_tool(self, tool_name: str, **arguments: Any) -> Any:
|
||||
return self.call_handler(tool_name=tool_name, **arguments)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_fake_instances() -> None:
|
||||
FakeTool.instances.clear()
|
||||
|
||||
|
||||
def _patch_tool() -> Any:
|
||||
"""Patch the lazy import inside ``_create_entry`` to substitute FakeTool."""
|
||||
import agent_framework
|
||||
|
||||
return patch.object(agent_framework, "MCPStreamableHTTPTool", FakeTool)
|
||||
|
||||
|
||||
def _invocation(
|
||||
*, server_url: str = "https://mcp.example/api", tool_name: str = "search", **overrides: Any
|
||||
) -> MCPToolInvocation:
|
||||
return MCPToolInvocation(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Construction ---------------------------------------------------
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_invalid_cache_size_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
DefaultMCPToolHandler(cache_max_size=0)
|
||||
with pytest.raises(ValueError):
|
||||
DefaultMCPToolHandler(cache_max_size=-3)
|
||||
|
||||
|
||||
# ---------- Tool kwargs ----------------------------------------------------
|
||||
|
||||
|
||||
class TestToolKwargs:
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_prompts_false_passed_to_tool(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].kwargs["load_prompts"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_label_used_as_tool_name(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label="MyMcp"))
|
||||
assert FakeTool.instances[0].kwargs["name"] == "MyMcp"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_tool_name_when_no_label(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label=None))
|
||||
assert FakeTool.instances[0].kwargs["name"] == "McpClient"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_header_provider_when_no_headers(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={}))
|
||||
assert FakeTool.instances[0].kwargs["header_provider"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_provider_returns_captured_headers(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer T"}))
|
||||
provider = FakeTool.instances[0].kwargs["header_provider"]
|
||||
assert provider({}) == {"Authorization": "Bearer T"}
|
||||
# Even if runtime kwargs change, captured headers stay the same.
|
||||
assert provider({"foo": "bar"}) == {"Authorization": "Bearer T"}
|
||||
|
||||
|
||||
# ---------- Cache behaviour ------------------------------------------------
|
||||
|
||||
|
||||
class TestCache:
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_url_and_headers_hit_cache(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
# One tool created, connect called once.
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_headers_create_separate_entries(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-A"}))
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-B"}))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_urls_create_separate_entries(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_url="https://mcp.a/api"))
|
||||
await handler.invoke_tool(_invocation(server_url="https://mcp.b/api"))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_eviction_closes_old_entry(self) -> None:
|
||||
handler = DefaultMCPToolHandler(cache_max_size=2)
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_url="https://a/"))
|
||||
await handler.invoke_tool(_invocation(server_url="https://b/"))
|
||||
# Inserting a third evicts the LRU entry (the first one).
|
||||
await handler.invoke_tool(_invocation(server_url="https://c/"))
|
||||
assert len(FakeTool.instances) == 3
|
||||
# First instance (https://a/) was evicted → close() called.
|
||||
assert FakeTool.instances[0].kwargs["url"] == "https://a/"
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
# Other two remain in cache → not closed.
|
||||
assert FakeTool.instances[1].close_count == 0
|
||||
assert FakeTool.instances[2].close_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_use_keeps_lru_alive(self) -> None:
|
||||
handler = DefaultMCPToolHandler(cache_max_size=2)
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_url="https://a/"))
|
||||
await handler.invoke_tool(_invocation(server_url="https://b/"))
|
||||
# Touch a → b becomes LRU.
|
||||
await handler.invoke_tool(_invocation(server_url="https://a/"))
|
||||
# Insert c → b is evicted.
|
||||
await handler.invoke_tool(_invocation(server_url="https://c/"))
|
||||
# b was evicted.
|
||||
b = FakeTool.instances[1]
|
||||
assert b.kwargs["url"] == "https://b/"
|
||||
assert b.close_count == 1
|
||||
# a survived.
|
||||
a = FakeTool.instances[0]
|
||||
assert a.kwargs["url"] == "https://a/"
|
||||
assert a.close_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_connect_shares_one_entry(self) -> None:
|
||||
"""Multiple concurrent invocations with the same key must share one tool."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
# Slow down connect so concurrency window is observable.
|
||||
original_connect = FakeTool.connect
|
||||
|
||||
async def slow_connect(self: FakeTool) -> None:
|
||||
self.connect_delay = 0.05
|
||||
await original_connect(self)
|
||||
|
||||
with _patch_tool(), patch.object(FakeTool, "connect", slow_connect):
|
||||
results = await asyncio.gather(
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
)
|
||||
assert all(not r.is_error for r in results)
|
||||
# Only one tool was created and connected, despite 4 concurrent calls.
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_connection_names_create_separate_entries(self) -> None:
|
||||
"""Same URL/headers but different ``connection_name`` must dispatch separately."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(connection_name="conn-A"))
|
||||
await handler.invoke_tool(_invocation(connection_name="conn-B"))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_server_labels_create_separate_entries(self) -> None:
|
||||
"""Same URL/headers but different ``server_label`` must dispatch separately."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label="LabelA"))
|
||||
await handler.invoke_tool(_invocation(server_label="LabelB"))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_identity_match_hits_cache(self) -> None:
|
||||
"""All four identity components match → single cached entry."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
|
||||
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_name_case_collapses_to_one_cache_entry(self) -> None:
|
||||
"""Header name spelling differences (case-only) must share a cache entry."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "tk"}))
|
||||
await handler.invoke_tool(_invocation(headers={"authorization": "tk"}))
|
||||
await handler.invoke_tool(_invocation(headers={"AUTHORIZATION": "tk"}))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_value_case_does_not_collapse(self) -> None:
|
||||
"""Header *values* remain case-sensitive (different tokens → different sessions)."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer-A"}))
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "bearer-a"}))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
|
||||
# ---------- Aclose semantics ----------------------------------------------
|
||||
|
||||
|
||||
class TestAclose:
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_closes_owned_clients(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
tool = FakeTool.instances[0]
|
||||
owned = tool._httpx_client
|
||||
assert owned is not None
|
||||
await handler.aclose()
|
||||
assert tool.close_count == 1
|
||||
assert owned.is_closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_does_not_close_caller_supplied_client(self) -> None:
|
||||
caller_client = httpx.AsyncClient()
|
||||
|
||||
async def provider(_inv: MCPToolInvocation) -> httpx.AsyncClient:
|
||||
return caller_client
|
||||
|
||||
handler = DefaultMCPToolHandler(client_provider=provider)
|
||||
try:
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
await handler.aclose()
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
# Caller client must still be usable.
|
||||
assert not caller_client.is_closed
|
||||
finally:
|
||||
await caller_client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(self) -> None:
|
||||
with _patch_tool():
|
||||
async with DefaultMCPToolHandler() as handler:
|
||||
await handler.invoke_tool(_invocation())
|
||||
tool = FakeTool.instances[0]
|
||||
assert tool.close_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_is_idempotent(self) -> None:
|
||||
"""A second ``aclose`` is a no-op (no exception, no double-close)."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
await handler.aclose()
|
||||
await handler.aclose()
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_after_close_returns_error_result(self) -> None:
|
||||
"""Post-close ``invoke_tool`` surfaces a tool error rather than crashing."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.aclose()
|
||||
result = await handler.invoke_tool(_invocation())
|
||||
assert result.is_error is True
|
||||
assert "closed" in (result.error_message or "").lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_drains_inflight_creation(self) -> None:
|
||||
"""An in-flight ``_create_entry`` must not leak when ``aclose`` races with it.
|
||||
|
||||
Reproduces the race described in PR #5630 review-comment 3:
|
||||
task A claims an inflight future and starts a slow connect; task B
|
||||
runs ``aclose``; task A must self-clean (close its tool + httpx
|
||||
client) and surface a closed-handler error rather than orphaning
|
||||
the entry.
|
||||
"""
|
||||
handler = DefaultMCPToolHandler()
|
||||
connect_started = asyncio.Event()
|
||||
release_connect = asyncio.Event()
|
||||
original_connect = FakeTool.connect
|
||||
|
||||
async def gated_connect(self: FakeTool) -> None:
|
||||
connect_started.set()
|
||||
await release_connect.wait()
|
||||
await original_connect(self)
|
||||
|
||||
with _patch_tool(), patch.object(FakeTool, "connect", gated_connect):
|
||||
invoke_task = asyncio.create_task(handler.invoke_tool(_invocation(headers={"X": "1"})))
|
||||
# Wait until task A is mid-connect.
|
||||
await connect_started.wait()
|
||||
# Race: kick off aclose. It must wait for the in-flight task.
|
||||
close_task = asyncio.create_task(handler.aclose())
|
||||
# Yield once to ensure aclose has set _closed and is awaiting.
|
||||
await asyncio.sleep(0)
|
||||
# Allow the connect to complete; phase 3 sees _closed and self-cleans.
|
||||
release_connect.set()
|
||||
result = await invoke_task
|
||||
await close_task
|
||||
|
||||
# Entry was created and then closed by the in-flight task itself.
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
# The originating invocation surfaces a closed-handler error.
|
||||
assert result.is_error is True
|
||||
assert "closed" in (result.error_message or "").lower()
|
||||
|
||||
|
||||
# ---------- Result normalisation ------------------------------------------
|
||||
|
||||
|
||||
class TestResultNormalisation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_result_wrapped_in_text_content(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
result = await handler.invoke_tool(inv)
|
||||
# The fake's default already returns a list; replace handler for this test.
|
||||
FakeTool.instances[0].call_handler = lambda **_a: "raw string body"
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 1
|
||||
assert result.outputs[0].text == "raw string body" # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_result_passed_through(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
custom = [Content.from_text("a"), Content.from_text("b")]
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = lambda **_a: custom
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 2
|
||||
|
||||
|
||||
# ---------- Error mapping --------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorMapping:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_execution_exception_returns_error_result(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise ToolExecutionException("server says no")
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is True
|
||||
assert result.error_message == "server says no"
|
||||
assert result.outputs[0].text.startswith("Error:") # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_error_returns_error_result(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise httpx.ConnectError("dns failure")
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is True
|
||||
assert "dns failure" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_exception_propagates(self) -> None:
|
||||
"""RuntimeError (not in the narrow catch list) must propagate."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise RuntimeError("programmer error")
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
with pytest.raises(RuntimeError, match="programmer error"):
|
||||
await handler.invoke_tool(inv)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure_returns_error_result(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with (
|
||||
_patch_tool(),
|
||||
patch.object(
|
||||
FakeTool,
|
||||
"connect",
|
||||
lambda self: (_ for _ in ()).throw(httpx.ConnectError("server down")),
|
||||
),
|
||||
):
|
||||
result = await handler.invoke_tool(_invocation())
|
||||
assert result.is_error is True
|
||||
assert result.outputs[0].text.startswith("Error:") # type: ignore[reportAttributeAccessIssue]
|
||||
# Failed connect must clear in-flight + cache entries.
|
||||
assert handler._inflight == {}
|
||||
assert len(handler._cache) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_error_propagates(self) -> None:
|
||||
"""asyncio.CancelledError is BaseException, must NOT be swallowed."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handler.invoke_tool(inv)
|
||||
|
||||
|
||||
# ---------- Cache key isolation -------------------------------------------
|
||||
|
||||
|
||||
class TestCacheKey:
|
||||
def test_key_order_independent(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1", "B": "2"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"B": "2", "A": "1"})
|
||||
assert k1 == k2
|
||||
|
||||
def test_key_distinguishes_values(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "2"})
|
||||
assert k1 != k2
|
||||
|
||||
def test_empty_headers_use_fixed_hash(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, None)
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {})
|
||||
assert k1 == k2
|
||||
|
||||
def test_key_distinguishes_connection_name(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-A", None)
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-B", None)
|
||||
assert k1 != k2
|
||||
|
||||
def test_key_distinguishes_server_label(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-A", None, None)
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-B", None, None)
|
||||
assert k1 != k2
|
||||
|
||||
def test_key_collapses_header_name_case(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"Authorization": "tk"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"authorization": "tk"})
|
||||
assert k1 == k2
|
||||
|
||||
def test_key_keeps_header_value_case(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
|
||||
assert k1 != k2
|
||||
@@ -0,0 +1,645 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for HttpRequestActionExecutor.
|
||||
|
||||
These tests use a stub HttpRequestHandler that returns canned HttpRequestResults.
|
||||
No real network or httpx transports are exercised. See
|
||||
test_default_http_request_handler.py for tests that exercise the real
|
||||
DefaultHttpRequestHandler against httpx.MockTransport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _powerfx_available or sys.version_info >= (3, 14),
|
||||
reason="PowerFx engine not available (requires dotnet runtime)",
|
||||
)
|
||||
|
||||
from agent_framework_declarative._workflows import ( # noqa: E402
|
||||
DECLARATIVE_STATE_KEY,
|
||||
DeclarativeActionError,
|
||||
DeclarativeWorkflowError,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
WorkflowFactory,
|
||||
)
|
||||
|
||||
|
||||
class StubHandler:
|
||||
"""Test stub that records the last call and returns a canned result."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: HttpRequestResult | None = None,
|
||||
*,
|
||||
raise_exc: BaseException | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.raise_exc = raise_exc
|
||||
self.last_info: HttpRequestInfo | None = None
|
||||
self.call_count = 0
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
self.call_count += 1
|
||||
self.last_info = info
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
assert self.result is not None
|
||||
return self.result
|
||||
|
||||
|
||||
def _ok(body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
|
||||
return HttpRequestResult(
|
||||
status_code=200,
|
||||
is_success_status_code=True,
|
||||
body=body,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
|
||||
def _err(status: int = 500, body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
|
||||
return HttpRequestResult(
|
||||
status_code=status,
|
||||
is_success_status_code=False,
|
||||
body=body,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
|
||||
async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
|
||||
"""Build & run a workflow, returning final WorkflowState."""
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(yaml_def)
|
||||
return await workflow.run({})
|
||||
|
||||
|
||||
def _state(workflow: Any, events: Any) -> dict[str, Any]:
|
||||
"""Read declarative state out of the workflow after run completes."""
|
||||
return workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
|
||||
|
||||
# Helper used by parametrised path tests
|
||||
_TEST_URL = "https://api.example.test/items"
|
||||
|
||||
|
||||
def _action(
|
||||
*,
|
||||
method: str | None = None,
|
||||
url: str = _TEST_URL,
|
||||
headers: dict[str, Any] | None = None,
|
||||
query_parameters: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
response: Any = None,
|
||||
response_headers: Any = None,
|
||||
conversation_id: str | None = None,
|
||||
request_timeout_ms: int | None = None,
|
||||
connection: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action: dict[str, Any] = {
|
||||
"kind": "HttpRequestAction",
|
||||
"id": "http_action",
|
||||
"url": url,
|
||||
}
|
||||
if method is not None:
|
||||
action["method"] = method
|
||||
if headers is not None:
|
||||
action["headers"] = headers
|
||||
if query_parameters is not None:
|
||||
action["queryParameters"] = query_parameters
|
||||
if body is not None:
|
||||
action["body"] = body
|
||||
if response is not None:
|
||||
action["response"] = response
|
||||
if response_headers is not None:
|
||||
action["responseHeaders"] = response_headers
|
||||
if conversation_id is not None:
|
||||
action["conversationId"] = conversation_id
|
||||
if request_timeout_ms is not None:
|
||||
action["requestTimeoutInMilliseconds"] = request_timeout_ms
|
||||
if connection is not None:
|
||||
action["connection"] = connection
|
||||
return action
|
||||
|
||||
|
||||
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"name": "http_test", "actions": [action]}
|
||||
|
||||
|
||||
# ---------- Success path: response parsing ----------------------------------
|
||||
|
||||
|
||||
class TestSuccessPath:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_parses_json_object(self) -> None:
|
||||
handler = StubHandler(_ok('{"key":"value","number":42}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "GET"
|
||||
assert handler.last_info.url == _TEST_URL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_parses_plain_string(self) -> None:
|
||||
handler = StubHandler(_ok("not-json content"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "not-json content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_empty_body_yields_none(self) -> None:
|
||||
handler = StubHandler(_ok(""))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_object_form_path(self) -> None:
|
||||
handler = StubHandler(_ok('{"x":1}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"x": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_response_path_does_not_assign(self) -> None:
|
||||
handler = StubHandler(_ok('{"x":1}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
# Should complete without error and without writing anything
|
||||
await workflow.run({})
|
||||
|
||||
|
||||
# ---------- Method / headers / query params --------------------------------
|
||||
|
||||
|
||||
class TestRequestComposition:
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_method_is_get(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "GET"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_method_uppercased(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(method="post")))
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "POST"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headers_are_forwarded_and_empty_skipped(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"X-Empty": "",
|
||||
"Authorization": "Bearer token",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.headers == {
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer token",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_parameters_stringified(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
query_parameters={
|
||||
"name": "alpha",
|
||||
"limit": 10,
|
||||
"active": True,
|
||||
"ratio": 0.5,
|
||||
"missing": None, # dropped
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.query_parameters == {
|
||||
"name": "alpha",
|
||||
"limit": "10",
|
||||
"active": "true",
|
||||
"ratio": "0.5",
|
||||
}
|
||||
|
||||
|
||||
# ---------- Body composition ------------------------------------------------
|
||||
|
||||
|
||||
class TestBody:
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_json_body_sets_content_type_and_serialises(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={"kind": "json", "content": {"k": "v", "n": 1}},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body_content_type == "application/json"
|
||||
assert info.body is not None
|
||||
# JSON serialized, key order may vary
|
||||
import json
|
||||
|
||||
assert json.loads(info.body) == {"k": "v", "n": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_raw_body_uses_declared_content_type(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={
|
||||
"kind": "raw",
|
||||
"content": "raw body text",
|
||||
"contentType": "text/plain",
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body == "raw body text"
|
||||
assert info.body_content_type == "text/plain"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_raw_body_without_content_type_defaults_to_text_plain(self) -> None:
|
||||
"""Match .NET RawRequestContent: no contentType => default text/plain.
|
||||
|
||||
Otherwise the request is sent without a Content-Type header which most
|
||||
servers will treat as application/octet-stream and fail to parse.
|
||||
"""
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={"kind": "raw", "content": "plain body"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body == "plain body"
|
||||
assert info.body_content_type == "text/plain"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_form_body_kinds_accepted(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
method="POST",
|
||||
body={"kind": "JsonRequestContent", "content": {"k": 1}},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body_content_type == "application/json"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_body_kind_raises(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(body={"kind": "weirdform", "content": "x"})))
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await workflow.run({})
|
||||
# Should surface as ValueError (potentially wrapped by runner)
|
||||
msg = str(excinfo.value)
|
||||
assert "weirdform" in msg or "unsupported value" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_body_omitted(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
await workflow.run({})
|
||||
info = handler.last_info
|
||||
assert info is not None
|
||||
assert info.body is None
|
||||
assert info.body_content_type is None
|
||||
|
||||
|
||||
# ---------- Non-2xx and error handling -------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_raises_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(_err(status=500, body="server exploded"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "500" in msg
|
||||
assert "server exploded" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_long_body_truncated(self) -> None:
|
||||
big_body = "A" * 1000
|
||||
handler = StubHandler(_err(status=500, body=big_body))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "[truncated]" in msg
|
||||
assert len(msg) < 512
|
||||
# Should NOT contain the full 1000-char body
|
||||
assert big_body not in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_empty_body_omits_body_section(self) -> None:
|
||||
handler = StubHandler(_err(status=404, body=""))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "404" in msg
|
||||
assert "Body:" not in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_control_chars_collapsed(self) -> None:
|
||||
handler = StubHandler(_err(status=500, body="line1\r\nline2\tlong"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "\r" not in msg
|
||||
assert "\n" not in msg
|
||||
assert "\t" not in msg
|
||||
assert "line1 line2 long" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_exception_becomes_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(raise_exc=httpx.TimeoutException("timeout"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
assert "timed out" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdlib_timeout_error_becomes_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(raise_exc=TimeoutError("clock"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
assert "timed out" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_error_becomes_declarative_action_error(self) -> None:
|
||||
handler = StubHandler(raise_exc=httpx.ConnectError("dns failure"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "failed" in msg
|
||||
assert _TEST_URL in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_error_propagates_unchanged(self) -> None:
|
||||
"""CancelledError from the handler must propagate so cancellation works."""
|
||||
handler = StubHandler(raise_exc=asyncio.CancelledError())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
# CancelledError is allowed to surface as either CancelledError or as
|
||||
# the runner's wrapped form, but it MUST NOT be DeclarativeActionError.
|
||||
with pytest.raises(BaseException) as excinfo:
|
||||
await workflow.run({})
|
||||
assert not isinstance(excinfo.value, DeclarativeActionError)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_exception_from_custom_handler_wrapped(self) -> None:
|
||||
"""A custom handler raising a non-httpx Exception must be wrapped.
|
||||
|
||||
Authors can plug in custom HttpRequestHandler implementations that use
|
||||
any transport (requests-like clients, gRPC bridges, mock test doubles,
|
||||
etc.). The executor must wrap arbitrary Exception subclasses uniformly
|
||||
so that workflow error handling stays consistent across transports.
|
||||
"""
|
||||
handler = StubHandler(raise_exc=RuntimeError("custom transport blew up"))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(DeclarativeActionError) as excinfo:
|
||||
await workflow.run({})
|
||||
msg = str(excinfo.value)
|
||||
assert "failed" in msg
|
||||
assert "RuntimeError" in msg
|
||||
assert _TEST_URL in msg
|
||||
|
||||
|
||||
# ---------- Response headers ------------------------------------------------
|
||||
|
||||
|
||||
class TestResponseHeaders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_headers_folded_with_commas(self) -> None:
|
||||
handler = StubHandler(
|
||||
_ok(
|
||||
"ok",
|
||||
headers={
|
||||
"Content-Type": ["application/json"],
|
||||
"Set-Cookie": ["a=1", "b=2"],
|
||||
},
|
||||
)
|
||||
)
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
h = decl["Local"]["H"]
|
||||
assert h["Content-Type"] == "application/json"
|
||||
assert h["Set-Cookie"] == "a=1,b=2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_headers_empty_assigned_none(self) -> None:
|
||||
handler = StubHandler(_ok("ok", headers={}))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_still_publishes_headers(self) -> None:
|
||||
handler = StubHandler(_err(status=500, body="boom", headers={"X-Trace": ["abc"]}))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
with pytest.raises(DeclarativeActionError):
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] == {"X-Trace": "abc"}
|
||||
|
||||
|
||||
# ---------- ConversationId append -------------------------------------------
|
||||
|
||||
|
||||
class TestConversationAppend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_id_appends_message(self) -> None:
|
||||
handler = StubHandler(_ok('{"answer":"hello"}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
response="Local.Result",
|
||||
conversation_id="conv-test-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"].get("conv-test-1")
|
||||
assert conv is not None
|
||||
assert len(conv["messages"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_conversation_id_does_not_append(self) -> None:
|
||||
handler = StubHandler(_ok('{"answer":"hello"}'))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# Auto-init creates an entry for the System.ConversationId conversation,
|
||||
# but it should NOT have HTTP-appended messages from us.
|
||||
for _cid, conv in decl["System"]["conversations"].items():
|
||||
assert conv["messages"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_body_skips_conversation_append(self) -> None:
|
||||
handler = StubHandler(_ok(""))
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# No conversation entry should have been created either.
|
||||
assert "conv-test-1" not in decl["System"]["conversations"]
|
||||
|
||||
|
||||
# ---------- Connection name -------------------------------------------------
|
||||
|
||||
|
||||
class TestConnection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_name_forwarded(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(connection={"name": "my-connection"})))
|
||||
await workflow.run({})
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.connection_name == "my-connection"
|
||||
|
||||
|
||||
# ---------- Build-time validation -------------------------------------------
|
||||
|
||||
|
||||
class TestBuildTimeValidation:
|
||||
def test_missing_url_fails_validation(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
bad = {
|
||||
"name": "no_url",
|
||||
"actions": [{"kind": "HttpRequestAction", "id": "x"}],
|
||||
}
|
||||
with pytest.raises(DeclarativeWorkflowError):
|
||||
factory.create_workflow_from_definition(bad)
|
||||
|
||||
def test_missing_handler_fails_at_build(self) -> None:
|
||||
factory = WorkflowFactory() # no handler
|
||||
with pytest.raises(DeclarativeWorkflowError) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(_action()))
|
||||
assert "http_request_handler" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- Timeout forwarding ----------------------------------------------
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_ms_forwarded(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=2500)))
|
||||
await workflow.run({})
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.timeout_ms == 2500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_ms_zero_treated_as_unset(self) -> None:
|
||||
handler = StubHandler(_ok())
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=0)))
|
||||
await workflow.run({})
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.timeout_ms is None
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""End-to-end YAML integration test for ``HttpRequestAction``.
|
||||
|
||||
Loads the ``tests/workflows/http_request.yaml`` fixture (parity with the .NET
|
||||
integration fixture) through ``WorkflowFactory.create_workflow_from_yaml_path``
|
||||
with a stub :class:`HttpRequestHandler` and asserts state is populated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
not _powerfx_available,
|
||||
reason="powerfx not available — declarative workflows require it.",
|
||||
),
|
||||
pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14),
|
||||
reason="Skipped on Python 3.14+ to keep parity with declarative suite.",
|
||||
),
|
||||
]
|
||||
|
||||
from agent_framework_declarative import WorkflowFactory # noqa: E402
|
||||
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY # noqa: E402
|
||||
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
)
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent / "workflows" / "http_request.yaml"
|
||||
|
||||
|
||||
class _StubHandler:
|
||||
"""Test double that records requests and returns a canned response."""
|
||||
|
||||
def __init__(self, result: HttpRequestResult) -> None:
|
||||
self._result = result
|
||||
self.received: list[HttpRequestInfo] = []
|
||||
|
||||
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
|
||||
self.received.append(info)
|
||||
return self._result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_request_yaml_roundtrip() -> None:
|
||||
handler = _StubHandler(
|
||||
HttpRequestResult(
|
||||
status_code=200,
|
||||
is_success_status_code=True,
|
||||
body='{"name": "runtime", "visibility": "public", "stars": 12345}',
|
||||
headers={
|
||||
"content-type": ["application/json"],
|
||||
"x-ratelimit-remaining": ["59"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
|
||||
await workflow.run({})
|
||||
|
||||
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
local = decl.get("Local") or {}
|
||||
|
||||
assert local.get("RepoOwner") == "dotnet"
|
||||
repo_info = local.get("RepoInfo")
|
||||
assert isinstance(repo_info, dict), f"Expected dict body, got {type(repo_info)!r}"
|
||||
assert repo_info["name"] == "runtime"
|
||||
assert repo_info["visibility"] == "public"
|
||||
assert repo_info["stars"] == 12345
|
||||
|
||||
repo_headers = local.get("RepoHeaders")
|
||||
assert isinstance(repo_headers, dict)
|
||||
# Single-value header surfaces as plain string.
|
||||
assert repo_headers.get("content-type") == "application/json"
|
||||
assert repo_headers.get("x-ratelimit-remaining") == "59"
|
||||
|
||||
# Stub got the right call.
|
||||
assert len(handler.received) == 1
|
||||
sent = handler.received[0]
|
||||
assert sent.method == "GET"
|
||||
assert sent.url == "https://api.github.com/repos/dotnet/runtime"
|
||||
assert sent.headers["Accept"] == "application/vnd.github+json"
|
||||
assert sent.headers["User-Agent"] == "agent-framework-integration-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_request_yaml_missing_handler_fails_at_build_time() -> None:
|
||||
"""Without an http_request_handler, building the workflow must raise."""
|
||||
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
|
||||
|
||||
factory = WorkflowFactory() # no handler configured
|
||||
with pytest.raises(DeclarativeWorkflowError) as excinfo:
|
||||
factory.create_workflow_from_yaml_path(FIXTURE_PATH)
|
||||
msg = str(excinfo.value)
|
||||
assert "HttpRequestAction" in msg
|
||||
assert "http_request_handler" in msg
|
||||
@@ -0,0 +1,664 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``InvokeMcpToolActionExecutor``.
|
||||
|
||||
Use a stub :class:`MCPToolHandler` that returns canned :class:`MCPToolResult`s.
|
||||
No real MCP server or network is exercised. See
|
||||
``test_default_mcp_tool_handler.py`` for tests that exercise the real
|
||||
``DefaultMCPToolHandler`` against a mocked ``MCPStreamableHTTPTool``.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _powerfx_available or sys.version_info >= (3, 14),
|
||||
reason="PowerFx engine not available (requires dotnet runtime)",
|
||||
)
|
||||
|
||||
from agent_framework import Content, Message # noqa: E402
|
||||
from agent_framework.exceptions import ToolExecutionException # noqa: E402
|
||||
|
||||
from agent_framework_declarative._workflows import ( # noqa: E402
|
||||
DECLARATIVE_STATE_KEY,
|
||||
DeclarativeWorkflowError,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
WorkflowFactory,
|
||||
)
|
||||
|
||||
|
||||
class StubMcpHandler:
|
||||
"""Test stub recording the last call and returning a canned result."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: MCPToolResult | None = None,
|
||||
*,
|
||||
raise_exc: BaseException | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.raise_exc = raise_exc
|
||||
self.last_invocation: MCPToolInvocation | None = None
|
||||
self.invocations: list[MCPToolInvocation] = []
|
||||
self.call_count = 0
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
self.call_count += 1
|
||||
self.last_invocation = invocation
|
||||
self.invocations.append(invocation)
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
assert self.result is not None
|
||||
return self.result
|
||||
|
||||
|
||||
def _ok(outputs: list[Content] | None = None) -> MCPToolResult:
|
||||
return MCPToolResult(outputs=outputs or [Content.from_text("hello")])
|
||||
|
||||
|
||||
def _err(message: str = "boom") -> MCPToolResult:
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
|
||||
def _action(
|
||||
*,
|
||||
server_url: str = "https://mcp.example/api",
|
||||
tool_name: str = "search",
|
||||
server_label: str | None = None,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
headers: dict[str, Any] | None = None,
|
||||
require_approval: Any = None,
|
||||
connection: dict[str, Any] | None = None,
|
||||
conversation_id: str | None = None,
|
||||
output: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action: dict[str, Any] = {
|
||||
"kind": "InvokeMcpTool",
|
||||
"id": "mcp_action",
|
||||
"serverUrl": server_url,
|
||||
"toolName": tool_name,
|
||||
}
|
||||
if server_label is not None:
|
||||
action["serverLabel"] = server_label
|
||||
if arguments is not None:
|
||||
action["arguments"] = arguments
|
||||
if headers is not None:
|
||||
action["headers"] = headers
|
||||
if require_approval is not None:
|
||||
action["requireApproval"] = require_approval
|
||||
if connection is not None:
|
||||
action["connection"] = connection
|
||||
if conversation_id is not None:
|
||||
action["conversationId"] = conversation_id
|
||||
if output is not None:
|
||||
action["output"] = output
|
||||
return action
|
||||
|
||||
|
||||
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"name": "mcp_test", "actions": [action]}
|
||||
|
||||
|
||||
# ---------- Builder enforcement --------------------------------------------
|
||||
|
||||
|
||||
class TestBuilderEnforcement:
|
||||
def test_missing_handler_raises_at_build_time(self) -> None:
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises(DeclarativeWorkflowError) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(_action()))
|
||||
assert "InvokeMcpTool" in str(excinfo.value)
|
||||
assert "mcp_tool_handler" in str(excinfo.value)
|
||||
|
||||
def test_missing_server_url_fails_validation(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
action = _action()
|
||||
del action["serverUrl"]
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(action))
|
||||
assert "serverUrl" in str(excinfo.value)
|
||||
|
||||
def test_missing_tool_name_fails_validation(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
action = _action()
|
||||
del action["toolName"]
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(action))
|
||||
assert "toolName" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- Field forwarding ----------------------------------------------
|
||||
|
||||
|
||||
class TestFieldForwarding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_invocation_forwards_required_fields(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
await workflow.run({})
|
||||
assert handler.call_count == 1
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
assert inv.server_url == "https://mcp.example/api"
|
||||
assert inv.tool_name == "search"
|
||||
assert inv.server_label is None
|
||||
assert inv.headers == {}
|
||||
assert inv.arguments == {}
|
||||
assert inv.connection_name is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arguments_evaluated_and_preserves_none(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
arguments={
|
||||
"query": "weather today",
|
||||
"limit": 5,
|
||||
"fresh": True,
|
||||
"missing": None,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
# ``None`` is preserved (parity with .NET) — caller decides.
|
||||
assert inv.arguments == {
|
||||
"query": "weather today",
|
||||
"limit": 5,
|
||||
"fresh": True,
|
||||
"missing": None,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headers_drop_empty_values(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
headers={
|
||||
"Authorization": "Bearer token-123",
|
||||
"X-Trace": "trace-id",
|
||||
"X-Empty": "",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
assert inv.headers == {
|
||||
"Authorization": "Bearer token-123",
|
||||
"X-Trace": "trace-id",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_label_and_connection_name_forwarded(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
server_label="docs-mcp",
|
||||
connection={"name": "azure-conn"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
assert inv.server_label == "docs-mcp"
|
||||
assert inv.connection_name == "azure-conn"
|
||||
|
||||
|
||||
# ---------- Output handling ------------------------------------------------
|
||||
|
||||
|
||||
class TestOutput:
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_result_parses_json_text(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text('{"k":"v","n":1}')]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_result_falls_back_to_raw_text(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("plain text not json")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["plain text not json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_messages_writes_single_tool_role_message(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("hi"), Content.from_text("there")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
msg = decl["Local"]["Messages"]
|
||||
# Single Tool-role message containing both contents (parity with .NET).
|
||||
assert isinstance(msg, Message)
|
||||
assert str(msg.role).lower() == "tool"
|
||||
assert len(msg.contents) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uri_content_serialised_as_uri_string(self) -> None:
|
||||
uri_content = Content.from_uri("https://example.com/file.txt", media_type="text/plain")
|
||||
handler = StubMcpHandler(_ok([uri_content]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_path_object_form(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("ok")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["ok"]
|
||||
|
||||
|
||||
# ---------- Conversation append --------------------------------------------
|
||||
|
||||
|
||||
class TestConversation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_id_appends_assistant_message(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
conversation_id="conv-42",
|
||||
output={"result": "Local.Result"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"]["conv-42"]
|
||||
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
|
||||
assert len(msgs) == 1
|
||||
appended = msgs[0]
|
||||
assert str(appended.role).lower() == "assistant"
|
||||
# Same contents as the tool output.
|
||||
assert len(appended.contents) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_conversation_id_does_not_append(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
conversation_id="",
|
||||
output={"result": "Local.Result"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# Empty conversation id must not produce a `""` entry under System.conversations.
|
||||
conversations = decl.get("System", {}).get("conversations", {})
|
||||
assert "" not in conversations
|
||||
|
||||
|
||||
# ---------- Approval flow --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state(): # type: ignore[no-untyped-def]
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
state = MagicMock()
|
||||
state._data = {}
|
||||
|
||||
def _get(key: str, default: Any = None) -> Any:
|
||||
if key not in state._data:
|
||||
if default is not None:
|
||||
return default
|
||||
raise KeyError(key)
|
||||
return state._data[key]
|
||||
|
||||
def _set(key: str, value: Any) -> None:
|
||||
state._data[key] = value
|
||||
|
||||
def _delete(key: str) -> None:
|
||||
if key in state._data:
|
||||
del state._data[key]
|
||||
else:
|
||||
raise KeyError(key)
|
||||
|
||||
state.get = MagicMock(side_effect=_get)
|
||||
state.set = MagicMock(side_effect=_set)
|
||||
state.delete = MagicMock(side_effect=_delete)
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context(mock_state): # type: ignore[no-untyped-def]
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.state = mock_state
|
||||
ctx.send_message = AsyncMock()
|
||||
ctx.yield_output = AsyncMock()
|
||||
ctx.request_info = AsyncMock()
|
||||
return ctx
|
||||
|
||||
|
||||
def _seed_state(mock_state) -> None: # type: ignore[no-untyped-def]
|
||||
"""Pre-seed the declarative state container as the executors expect."""
|
||||
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
|
||||
|
||||
mock_state._data[DECLARATIVE_STATE_KEY] = {
|
||||
"Local": {},
|
||||
"Custom": {},
|
||||
"Workflow": {},
|
||||
"System": {
|
||||
"ConversationId": "00000000-0000-0000-0000-000000000000",
|
||||
"LastMessage": {"Id": "", "Text": ""},
|
||||
"LastMessageText": "",
|
||||
"LastMessageId": "",
|
||||
},
|
||||
"Agent": {},
|
||||
"Conversation": {"messages": [], "history": []},
|
||||
"Inputs": {},
|
||||
}
|
||||
|
||||
|
||||
class TestApprovalFlow:
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_required_emits_request_and_yields(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
|
||||
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
|
||||
from agent_framework_declarative._workflows._executors_mcp import (
|
||||
_MCP_APPROVAL_STATE_KEY,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
)
|
||||
|
||||
_seed_state(mock_state)
|
||||
handler = StubMcpHandler(_ok())
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
_action(
|
||||
require_approval=True,
|
||||
arguments={"q": "x"},
|
||||
headers={"Authorization": "Bearer SECRET"},
|
||||
output={"result": "Local.Result"},
|
||||
),
|
||||
mcp_tool_handler=handler,
|
||||
)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Approval request emitted.
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, MCPToolApprovalRequest)
|
||||
assert request.tool_name == "search"
|
||||
assert request.arguments == {"q": "x"}
|
||||
assert request.header_names == ["Authorization"]
|
||||
|
||||
# NEVER expose the actual auth token in any field of the approval payload.
|
||||
for value in request.__dict__.values():
|
||||
assert "SECRET" not in str(value)
|
||||
|
||||
# Workflow should yield (no ActionComplete sent yet).
|
||||
mock_context.send_message.assert_not_called()
|
||||
|
||||
# Handler not invoked yet.
|
||||
assert handler.call_count == 0
|
||||
|
||||
# Approval state stored.
|
||||
approval_key = f"{_MCP_APPROVAL_STATE_KEY}_mcp_action"
|
||||
assert approval_key in mock_state._data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_response_approved_invokes_handler(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
|
||||
from agent_framework_declarative._workflows import ActionComplete, ToolApprovalResponse
|
||||
from agent_framework_declarative._workflows._executors_mcp import (
|
||||
_MCP_APPROVAL_STATE_KEY,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
_MCPToolApprovalState,
|
||||
)
|
||||
|
||||
_seed_state(mock_state)
|
||||
handler = StubMcpHandler(_ok([Content.from_text('{"ok":true}')]))
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
_action(
|
||||
require_approval=True,
|
||||
output={"result": "Local.Result"},
|
||||
),
|
||||
mcp_tool_handler=handler,
|
||||
)
|
||||
# Pre-populate approval state.
|
||||
approval_key = f"{_MCP_APPROVAL_STATE_KEY}_mcp_action"
|
||||
mock_state._data[approval_key] = _MCPToolApprovalState(
|
||||
server_url="https://mcp.example/api",
|
||||
tool_name="search",
|
||||
server_label=None,
|
||||
arguments={"q": "x"},
|
||||
connection_name=None,
|
||||
headers_def={"Authorization": "Bearer tk"},
|
||||
auto_send=False,
|
||||
conversation_id_expr=None,
|
||||
output_messages_path=None,
|
||||
output_result_path="Local.Result",
|
||||
)
|
||||
await executor.handle_approval_response(
|
||||
MCPToolApprovalRequest(
|
||||
request_id="req-1",
|
||||
tool_name="search",
|
||||
server_url="https://mcp.example/api",
|
||||
server_label=None,
|
||||
arguments={"q": "x"},
|
||||
),
|
||||
ToolApprovalResponse(approved=True),
|
||||
mock_context,
|
||||
)
|
||||
|
||||
assert handler.call_count == 1
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
# Headers are re-evaluated from headers_def.
|
||||
assert inv.headers == {"Authorization": "Bearer tk"}
|
||||
# Approval state was cleaned up.
|
||||
assert approval_key not in mock_state._data
|
||||
# ActionComplete was sent.
|
||||
mock_context.send_message.assert_called_once()
|
||||
sent = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(sent, ActionComplete)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_response_rejected_assigns_error(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
|
||||
from agent_framework_declarative._workflows import ToolApprovalResponse
|
||||
from agent_framework_declarative._workflows._executors_mcp import (
|
||||
_MCP_APPROVAL_STATE_KEY,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
_MCPToolApprovalState,
|
||||
)
|
||||
|
||||
_seed_state(mock_state)
|
||||
handler = StubMcpHandler(_ok())
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
_action(
|
||||
require_approval=True,
|
||||
output={"result": "Local.Result"},
|
||||
),
|
||||
mcp_tool_handler=handler,
|
||||
)
|
||||
approval_key = f"{_MCP_APPROVAL_STATE_KEY}_mcp_action"
|
||||
mock_state._data[approval_key] = _MCPToolApprovalState(
|
||||
server_url="https://mcp.example/api",
|
||||
tool_name="search",
|
||||
server_label=None,
|
||||
arguments={},
|
||||
connection_name=None,
|
||||
headers_def=None,
|
||||
auto_send=True,
|
||||
conversation_id_expr=None,
|
||||
output_messages_path=None,
|
||||
output_result_path="Local.Result",
|
||||
)
|
||||
await executor.handle_approval_response(
|
||||
MCPToolApprovalRequest(
|
||||
request_id="req-2",
|
||||
tool_name="search",
|
||||
server_url="https://mcp.example/api",
|
||||
server_label=None,
|
||||
arguments={},
|
||||
),
|
||||
ToolApprovalResponse(approved=False, reason="not authorized"),
|
||||
mock_context,
|
||||
)
|
||||
|
||||
assert handler.call_count == 0
|
||||
# Error string assigned at output.result.
|
||||
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
|
||||
|
||||
result = mock_state._data[DECLARATIVE_STATE_KEY]["Local"]["Result"]
|
||||
assert result == "Error: MCP tool invocation was not approved by user."
|
||||
|
||||
|
||||
# ---------- Error handling -------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_returns_error_result_assigns_error_string(self) -> None:
|
||||
handler = StubMcpHandler(_err("server down"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: server down"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_execution_exception_becomes_error_result(self) -> None:
|
||||
handler = StubMcpHandler(raise_exc=ToolExecutionException("invalid arguments"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: invalid arguments"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_error_becomes_error_result(self) -> None:
|
||||
handler = StubMcpHandler(raise_exc=httpx.ConnectError("dns fail"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
result = decl["Local"]["Result"]
|
||||
assert isinstance(result, str)
|
||||
assert result.startswith("Error:")
|
||||
assert "ConnectError" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_exception_propagates(self) -> None:
|
||||
"""Programmer bugs (TypeError etc.) must NOT be swallowed."""
|
||||
handler = StubMcpHandler(raise_exc=TypeError("bad type"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await workflow.run({})
|
||||
# Either the TypeError reaches us or it gets wrapped by the runner —
|
||||
# either way the message must surface.
|
||||
assert "bad type" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- autoSend -------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoSend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_send_default_true_yields_output(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
events = await workflow.run({})
|
||||
outputs = events.get_outputs()
|
||||
assert len(outputs) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_send_false_suppresses_yield(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"autoSend": False})))
|
||||
events = await workflow.run({})
|
||||
outputs = events.get_outputs()
|
||||
assert outputs == []
|
||||
|
||||
|
||||
# ---------- Protocol structure --------------------------------------------
|
||||
|
||||
|
||||
class TestProtocol:
|
||||
def test_stub_handler_satisfies_protocol(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
assert isinstance(handler, MCPToolHandler)
|
||||
|
||||
|
||||
# ---------- _format_outputs_for_send --------------------------------------
|
||||
|
||||
|
||||
class TestFormatOutputsForSend:
|
||||
"""Direct tests for the auto-send rendering helper.
|
||||
|
||||
Regression for PR #5630 review-comment 4: a single scalar JSON value
|
||||
must render bare (e.g. ``"42"``) rather than wrapped (``"[42]"``).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parsed", "expected"),
|
||||
[
|
||||
([], ""),
|
||||
(["hello"], "hello"),
|
||||
(["a", "b"], "a\nb"),
|
||||
([42], "42"),
|
||||
([3.14], "3.14"),
|
||||
([True], "true"),
|
||||
([False], "false"),
|
||||
([None], "null"),
|
||||
([{"k": "v"}], '{"k": "v"}'),
|
||||
([[1, 2]], "[1, 2]"),
|
||||
(["hello", 42], '["hello", 42]'),
|
||||
([{"a": 1}, {"b": 2}], '[{"a": 1}, {"b": 2}]'),
|
||||
],
|
||||
)
|
||||
def test_format_outputs_for_send(self, parsed: list[Any], expected: str) -> None:
|
||||
from agent_framework_declarative._workflows._executors_mcp import _format_outputs_for_send
|
||||
|
||||
assert _format_outputs_for_send(parsed) == expected
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_declarative._workflows._factory import (
|
||||
DeclarativeWorkflowError,
|
||||
WorkflowFactory,
|
||||
)
|
||||
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
|
||||
from agent_framework_declarative._workflows._factory import WorkflowFactory
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#
|
||||
# Integration fixture: end-to-end HttpRequestAction round-trip using a
|
||||
# stub HttpRequestHandler. Mirrors the .NET integration fixture in
|
||||
# dotnet/tests/.../Workflows/HttpRequest.yaml.
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_http_request_test
|
||||
actions:
|
||||
|
||||
# Set the repo owner used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_owner
|
||||
variable: Local.RepoOwner
|
||||
value: dotnet
|
||||
|
||||
# Invoke the (stubbed) GitHub repo API.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-integration-test
|
||||
response: Local.RepoInfo
|
||||
responseHeaders: Local.RepoHeaders
|
||||
-1
@@ -52,7 +52,6 @@ class TestMultiAgentOrchestrationConditionals:
|
||||
assert email_agent is not None
|
||||
assert email_agent.name == EMAIL_AGENT_NAME
|
||||
|
||||
@pytest.mark.skip(reason="Consistently fails due to orchestration timeouts - needs investigation")
|
||||
def test_conditional_branching(self):
|
||||
"""Test that conditional branching works correctly."""
|
||||
# Test with obvious spam
|
||||
|
||||
@@ -634,7 +634,6 @@ async def test_foundry_agent_configure_azure_monitor_import_error() -> None:
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_agent_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.")
|
||||
async def test_foundry_agent_basic_run() -> None:
|
||||
"""Smoke-test FoundryAgent against a real configured agent."""
|
||||
async with FoundryAgent(credential=AzureCliCredential(), allow_preview=True) as agent:
|
||||
@@ -648,10 +647,11 @@ async def test_foundry_agent_basic_run() -> None:
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_agent_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.")
|
||||
async def test_foundry_agent_custom_client_run() -> None:
|
||||
"""Smoke-test FoundryAgent against a real configured agent."""
|
||||
async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent:
|
||||
async with FoundryAgent(
|
||||
credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient, allow_preview=True
|
||||
) as agent:
|
||||
response = await agent.run("Please respond with exactly: 'This is a response test.'")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
|
||||
@@ -806,6 +806,18 @@ def _item_to_message(item: Item) -> Message:
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(ItemCustomToolCallOutput, item)
|
||||
output = cto.output if isinstance(cto.output, str) else str(cto.output)
|
||||
# Hosted-MCP results land here because the host writes them via
|
||||
# `aoutput_item_custom_tool_call_output` (see `_to_outputs` for
|
||||
# `mcp_server_tool_result`). The persisted `call_id` keeps its
|
||||
# `mcp_*` prefix; on read, route those back to a hosted-MCP result
|
||||
# Content so the chat-client serialize layer can coalesce them
|
||||
# onto a single `mcp_call` input item with `output` populated.
|
||||
# Issue #5546.
|
||||
if cto.call_id and cto.call_id.startswith("mcp_"):
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
|
||||
)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
@@ -1054,6 +1066,16 @@ def _output_item_to_message(item: OutputItem) -> Message:
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(OutputItemCustomToolCallOutput, item)
|
||||
output = cto.output if isinstance(cto.output, str) else str(cto.output)
|
||||
# Hosted-MCP results land here because the host writes them via
|
||||
# `aoutput_item_custom_tool_call_output`. Route `mcp_*` call_ids
|
||||
# back to a hosted-MCP result Content so the chat-client serialize
|
||||
# layer can coalesce onto the matching `mcp_call` input item.
|
||||
# Issue #5546.
|
||||
if cto.call_id and cto.call_id.startswith("mcp_"):
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
|
||||
)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
|
||||
@@ -879,6 +879,30 @@ class TestOutputItemToMessage:
|
||||
assert msg.contents[0].type == "function_result"
|
||||
assert msg.contents[0].result == "result text"
|
||||
|
||||
def test_custom_tool_call_output_with_mcp_call_id_routes_to_mcp_server_tool_result(self) -> None:
|
||||
"""When the host wrote a hosted-MCP result via
|
||||
`aoutput_item_custom_tool_call_output`, the persisted call_id keeps
|
||||
its `mcp_*` prefix. On read, that result must reconstruct as a
|
||||
`mcp_server_tool_result` Content (not `function_result`), so the
|
||||
chat-client serialize layer treats it as a hosted-MCP result and
|
||||
does not produce an orphan `function_call_output`.
|
||||
"""
|
||||
from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput
|
||||
|
||||
item = OutputItemCustomToolCallOutput({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920",
|
||||
"output": "found 10 cats",
|
||||
})
|
||||
msg = _output_item_to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert len(msg.contents) == 1
|
||||
c = msg.contents[0]
|
||||
assert c.type == "mcp_server_tool_result", (
|
||||
f"expected mcp_server_tool_result for mcp_-prefixed call_id; got {c.type}"
|
||||
)
|
||||
assert c.call_id == "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920"
|
||||
|
||||
def test_apply_patch_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall
|
||||
|
||||
@@ -1329,6 +1353,32 @@ class TestItemToMessage:
|
||||
assert msg is not None
|
||||
assert msg.contents[0].result == "123"
|
||||
|
||||
def test_custom_tool_call_output_with_mcp_call_id_routes_to_mcp_server_tool_result(self) -> None:
|
||||
"""Issue #5546: input items carrying a hosted-MCP result (from a
|
||||
prior turn that the framework wrote via
|
||||
`aoutput_item_custom_tool_call_output`) must reconstruct as a
|
||||
`mcp_server_tool_result` Content, not `function_result`. Otherwise
|
||||
the chat-client serialize layer turns it into an orphan
|
||||
`function_call_output` with `mcp_*` call_id and the Responses API
|
||||
rejects the next turn.
|
||||
"""
|
||||
from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput
|
||||
|
||||
item = ItemCustomToolCallOutput({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920",
|
||||
"output": "found 10 cats",
|
||||
})
|
||||
msg = _item_to_message(item)
|
||||
assert msg is not None
|
||||
assert msg.role == "tool"
|
||||
assert len(msg.contents) == 1
|
||||
c = msg.contents[0]
|
||||
assert c.type == "mcp_server_tool_result", (
|
||||
f"expected mcp_server_tool_result for mcp_-prefixed call_id; got {c.type}"
|
||||
)
|
||||
assert c.call_id == "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920"
|
||||
|
||||
def test_apply_patch_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ApplyPatchToolCallItemParam, ApplyPatchUpdateFileOperation
|
||||
|
||||
|
||||
@@ -559,25 +559,21 @@ class TestToolCalling:
|
||||
class TestOptions:
|
||||
"""Verify chat options are passed through to the model."""
|
||||
|
||||
@pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.")
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_hosting_integration_tests_disabled
|
||||
async def test_temperature_and_max_tokens(self, server: ResponsesHostServer) -> None:
|
||||
"""Set temperature and max_output_tokens and verify the response succeeds."""
|
||||
"""Set max_output_tokens and verify the response succeeds."""
|
||||
resp = await _post_json(
|
||||
server,
|
||||
{
|
||||
"input": "Say hello briefly.",
|
||||
"stream": False,
|
||||
"max_output_tokens": 50,
|
||||
"max_output_tokens": 200,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
output_messages = [o for o in body["output"] if o["type"] == "message"]
|
||||
assert len(output_messages) == 1
|
||||
output_text = output_messages[0]["content"][0]["text"]
|
||||
assert len(output_text) > 0
|
||||
assert len(body["output"]) > 0
|
||||
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
|
||||
from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -59,6 +60,59 @@ DEFAULT_TIMEOUT_SECONDS: float = 60.0
|
||||
PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult]
|
||||
"""Type for permission request handlers."""
|
||||
|
||||
|
||||
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
|
||||
"""Callback invoked by the agent before executing a FunctionTool that requires approval.
|
||||
|
||||
The callback receives a ``FunctionCallContent`` describing the pending call
|
||||
(``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
|
||||
to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
|
||||
return values are supported.
|
||||
|
||||
The Copilot CLI manages its own tool-calling loop, so the framework cannot
|
||||
round-trip a ``FunctionApprovalRequestContent`` / ``FunctionApprovalResponseContent``
|
||||
pair the way the standard chat-client pipeline does. This callback is the
|
||||
agent-level enforcement point for tools declared with
|
||||
``approval_mode="always_require"``: when no callback is configured the agent
|
||||
denies these calls by default.
|
||||
|
||||
Note: this is independent of ``on_permission_request``, which gates the
|
||||
Copilot SDK's *built-in* shell/file actions; ``on_function_approval`` gates
|
||||
agent-framework ``FunctionTool`` calls.
|
||||
"""
|
||||
|
||||
|
||||
async def _resolve_function_approval(
|
||||
callback: FunctionApprovalCallback | None,
|
||||
func_tool: FunctionTool,
|
||||
arguments: Mapping[str, Any] | None,
|
||||
) -> bool:
|
||||
"""Run the agent-level approval callback for a pending tool call.
|
||||
|
||||
Returns ``True`` only when ``callback`` is configured and explicitly returns
|
||||
a truthy value. A missing callback or any callback failure is treated as a
|
||||
denial so the secure-by-default policy holds even if the user code raises.
|
||||
"""
|
||||
if callback is None:
|
||||
return False
|
||||
request = Content.from_function_call(
|
||||
call_id=f"af-copilot-approval::{func_tool.name}",
|
||||
name=func_tool.name,
|
||||
arguments=None if arguments is None else dict(arguments),
|
||||
)
|
||||
try:
|
||||
outcome = callback(request)
|
||||
if inspect.isawaitable(outcome):
|
||||
outcome = await outcome
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"on_function_approval callback raised for tool '%s'; denying execution.",
|
||||
func_tool.name,
|
||||
)
|
||||
return False
|
||||
return bool(outcome)
|
||||
|
||||
|
||||
logger = logging.getLogger("agent_framework.github_copilot")
|
||||
|
||||
|
||||
@@ -133,6 +187,14 @@ class GitHubCopilotOptions(TypedDict, total=False):
|
||||
instead of the default GitHub Copilot backend.
|
||||
"""
|
||||
|
||||
on_function_approval: FunctionApprovalCallback
|
||||
"""Approval callback for ``FunctionTool`` instances declared with
|
||||
``approval_mode="always_require"``. The callback is awaited (sync or async)
|
||||
inside the SDK tool-handler before the tool is executed; a falsy return
|
||||
value denies the call. If omitted, calls to such tools are denied with an
|
||||
explanatory message returned to the model. This is independent of
|
||||
``on_permission_request``, which gates the Copilot SDK's built-in actions."""
|
||||
|
||||
|
||||
OptionsT = TypeVar(
|
||||
"OptionsT",
|
||||
@@ -238,6 +300,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
|
||||
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
|
||||
provider: ProviderConfig | None = opts.pop("provider", None)
|
||||
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
|
||||
|
||||
self._settings = load_settings(
|
||||
GitHubCopilotSettings,
|
||||
@@ -252,6 +315,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
self._tools = normalize_tools(tools)
|
||||
self._permission_handler = on_permission_request
|
||||
self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
|
||||
self._mcp_servers = mcp_servers
|
||||
self._provider = provider
|
||||
self._default_options = opts
|
||||
@@ -425,6 +489,12 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
session = self.create_session()
|
||||
|
||||
opts: dict[str, Any] = dict(options) if options else {}
|
||||
if "on_function_approval" in opts:
|
||||
raise ValueError(
|
||||
"on_function_approval is a security-sensitive option and must be set "
|
||||
"via default_options at agent construction time. It cannot be overridden "
|
||||
"per run."
|
||||
)
|
||||
timeout = opts.get("timeout") or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
@@ -504,6 +574,12 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
session = self.create_session()
|
||||
|
||||
opts: dict[str, Any] = dict(options) if options else {}
|
||||
if "on_function_approval" in opts:
|
||||
raise ValueError(
|
||||
"on_function_approval is a security-sensitive option and must be set "
|
||||
"via default_options at agent construction time. It cannot be overridden "
|
||||
"per run."
|
||||
)
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
|
||||
@@ -681,10 +757,33 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
|
||||
def _tool_to_copilot_tool(self, ai_func: FunctionTool) -> CopilotTool:
|
||||
"""Convert an FunctionTool to a Copilot SDK tool."""
|
||||
approval_handler = self._function_approval_handler
|
||||
requires_approval = ai_func.approval_mode == "always_require"
|
||||
|
||||
async def handler(invocation: ToolInvocation) -> ToolResult:
|
||||
args: dict[str, Any] = invocation.arguments or {}
|
||||
try:
|
||||
if requires_approval and not await _resolve_function_approval(approval_handler, ai_func, args):
|
||||
deny_text = (
|
||||
f"Tool '{ai_func.name}' requires human approval "
|
||||
"(approval_mode='always_require') and the request was denied."
|
||||
if approval_handler is not None
|
||||
else (
|
||||
f"Tool '{ai_func.name}' requires human approval "
|
||||
"(approval_mode='always_require') but no on_function_approval "
|
||||
"callback is configured on the agent; the request was denied."
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Denying execution of tool '%s' (approval_mode='always_require', %s)",
|
||||
ai_func.name,
|
||||
"callback denied" if approval_handler is not None else "no callback configured",
|
||||
)
|
||||
return ToolResult(
|
||||
text_result_for_llm=deny_text,
|
||||
result_type="failure",
|
||||
error="approval_denied",
|
||||
)
|
||||
if ai_func.input_model:
|
||||
args_instance = ai_func.input_model(**args)
|
||||
result = await ai_func.invoke(arguments=args_instance)
|
||||
|
||||
@@ -1483,6 +1483,183 @@ class TestGitHubCopilotAgentToolConversion:
|
||||
assert result[1] == copilot_tool
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentFunctionApproval:
|
||||
"""Tests that ``approval_mode='always_require'`` is enforced at the agent boundary."""
|
||||
|
||||
async def test_handler_denies_when_no_callback_configured(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Approval-required tool must be denied without executing when no callback is set."""
|
||||
from agent_framework import tool
|
||||
|
||||
invocations: list[Any] = []
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(path: str) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(path)
|
||||
return f"deleted {path}"
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
copilot_tool = agent._tool_to_copilot_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"path": "/critical"}))
|
||||
|
||||
assert invocations == []
|
||||
assert result.result_type == "failure"
|
||||
assert result.error == "approval_denied"
|
||||
assert "no on_function_approval callback is configured" in result.text_result_for_llm
|
||||
|
||||
async def test_handler_denies_when_callback_returns_false(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Falsy callback return value must deny the call and skip execution."""
|
||||
from agent_framework import Content, tool
|
||||
|
||||
invocations: list[Any] = []
|
||||
seen: list[Content] = []
|
||||
|
||||
def deny(call: Content) -> bool:
|
||||
seen.append(call)
|
||||
return False
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(path: str) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(path)
|
||||
return f"deleted {path}"
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
default_options={"on_function_approval": deny},
|
||||
)
|
||||
copilot_tool = agent._tool_to_copilot_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"path": "/critical"}))
|
||||
|
||||
assert invocations == []
|
||||
assert len(seen) == 1
|
||||
assert seen[0].type == "function_call"
|
||||
assert seen[0].name == "dangerous" # type: ignore[attr-defined]
|
||||
assert seen[0].arguments == {"path": "/critical"} # type: ignore[attr-defined]
|
||||
assert result.result_type == "failure"
|
||||
assert result.error == "approval_denied"
|
||||
|
||||
async def test_handler_executes_when_callback_returns_true(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Truthy callback return value must allow the tool to execute normally."""
|
||||
from agent_framework import Content, tool
|
||||
|
||||
def approve(call: Content) -> bool:
|
||||
return True
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def guarded(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
return f"result={x}"
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
default_options={"on_function_approval": approve},
|
||||
)
|
||||
copilot_tool = agent._tool_to_copilot_tool(guarded) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"x": 42}))
|
||||
|
||||
assert result.result_type == "success"
|
||||
assert result.text_result_for_llm == "result=42"
|
||||
|
||||
async def test_handler_supports_async_callback(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Async callback must be awaited and respected."""
|
||||
from agent_framework import Content, tool
|
||||
|
||||
async def approve(call: Content) -> bool:
|
||||
return True
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def guarded(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
return f"async={x}"
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
default_options={"on_function_approval": approve},
|
||||
)
|
||||
copilot_tool = agent._tool_to_copilot_tool(guarded) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"x": 7}))
|
||||
|
||||
assert result.result_type == "success"
|
||||
assert result.text_result_for_llm == "async=7"
|
||||
|
||||
async def test_callback_failure_denies_safely(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""A callback that raises must result in denial, not in tool execution."""
|
||||
from agent_framework import Content, tool
|
||||
|
||||
invocations: list[Any] = []
|
||||
|
||||
def boom(call: Content) -> bool:
|
||||
raise RuntimeError("nope")
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(x)
|
||||
return f"x={x}"
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
default_options={"on_function_approval": boom},
|
||||
)
|
||||
copilot_tool = agent._tool_to_copilot_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"x": 1}))
|
||||
|
||||
assert invocations == []
|
||||
assert result.result_type == "failure"
|
||||
assert result.error == "approval_denied"
|
||||
|
||||
async def test_handler_does_not_invoke_callback_for_never_require(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Tools without approval_mode='always_require' must not trigger the callback."""
|
||||
from agent_framework import Content, tool
|
||||
|
||||
callback_calls: list[Any] = []
|
||||
|
||||
def approve(call: Content) -> bool:
|
||||
callback_calls.append(call)
|
||||
return True
|
||||
|
||||
@tool
|
||||
def safe(x: int) -> str:
|
||||
"""A tool that does not require approval."""
|
||||
return f"safe={x}"
|
||||
|
||||
agent = GitHubCopilotAgent(
|
||||
client=mock_client,
|
||||
default_options={"on_function_approval": approve},
|
||||
)
|
||||
copilot_tool = agent._tool_to_copilot_tool(safe) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await copilot_tool.handler(ToolInvocation(arguments={"x": 5}))
|
||||
|
||||
assert callback_calls == []
|
||||
assert result.result_type == "success"
|
||||
assert result.text_result_for_llm == "safe=5"
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentErrorHandling:
|
||||
"""Test cases for error handling."""
|
||||
|
||||
@@ -2128,3 +2305,16 @@ class TestGitHubCopilotAgentContextProviders:
|
||||
await agent.run("Hello", session=session, options={"timeout": 120})
|
||||
|
||||
assert observed_options.get("timeout") == 120
|
||||
|
||||
async def test_runtime_on_function_approval_rejected(self, mock_client: MagicMock) -> None:
|
||||
"""Passing on_function_approval at runtime must raise rather than be silently ignored."""
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
with pytest.raises(ValueError, match="on_function_approval"):
|
||||
await agent.run("hello", options={"on_function_approval": lambda _c: True})
|
||||
|
||||
async def test_runtime_on_function_approval_rejected_streaming(self, mock_client: MagicMock) -> None:
|
||||
"""Passing on_function_approval at runtime must raise on the streaming path too."""
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
with pytest.raises(ValueError, match="on_function_approval"):
|
||||
async for _ in agent.run("hello", stream=True, options={"on_function_approval": lambda _c: True}):
|
||||
pass
|
||||
|
||||
@@ -150,6 +150,12 @@ def hello_world(arg1: str) -> str:
|
||||
return "Hello World"
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def greet() -> str:
|
||||
"""Say hello to the world. No-arg tool for integration tests to avoid argument parsing flakiness."""
|
||||
return "Hello World"
|
||||
|
||||
|
||||
def test_init(ollama_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
ollama_chat_client = OllamaChatClient()
|
||||
@@ -500,10 +506,10 @@ async def test_cmc_with_invalid_content_type(
|
||||
async def test_cmc_integration_with_tool_call(
|
||||
chat_history: list[Message],
|
||||
) -> None:
|
||||
chat_history.append(Message(contents=["Call the hello world function and repeat what it says"], role="user"))
|
||||
chat_history.append(Message(contents=["Call the greet function and repeat what it says"], role="user"))
|
||||
|
||||
ollama_client = OllamaChatClient()
|
||||
result = await ollama_client.get_response(messages=chat_history, options={"tools": [hello_world]})
|
||||
result = await ollama_client.get_response(messages=chat_history, options={"tools": [greet]})
|
||||
|
||||
assert "hello" in result.text.lower() and "world" in result.text.lower()
|
||||
assert result.messages[-2].contents[0].type == "function_result"
|
||||
@@ -531,11 +537,11 @@ async def test_cmc_integration_with_chat_completion(
|
||||
async def test_cmc_streaming_integration_with_tool_call(
|
||||
chat_history: list[Message],
|
||||
) -> None:
|
||||
chat_history.append(Message(contents=["Call the hello world function and repeat what it says"], role="user"))
|
||||
chat_history.append(Message(contents=["Call the greet function and repeat what it says"], role="user"))
|
||||
|
||||
ollama_client = OllamaChatClient()
|
||||
result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(
|
||||
messages=chat_history, stream=True, options={"tools": [hello_world]}
|
||||
messages=chat_history, stream=True, options={"tools": [greet]}
|
||||
)
|
||||
|
||||
chunks: list[ChatResponseUpdate] = []
|
||||
@@ -549,7 +555,7 @@ async def test_cmc_streaming_integration_with_tool_call(
|
||||
assert tool_result.result == "Hello World"
|
||||
if c.contents[0].type == "function_call":
|
||||
tool_call = c.contents[0]
|
||||
assert tool_call.name == "hello_world"
|
||||
assert tool_call.name == "greet"
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
|
||||
@@ -121,6 +121,14 @@ OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY = "openai.local_shell_command_parts"
|
||||
OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL = "shell_call_output"
|
||||
OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL = "local_shell_call_output"
|
||||
|
||||
# Internal marker emitted by `_prepare_content_for_openai` for an
|
||||
# `mcp_server_tool_result` Content. The Responses API expects an `mcp_call`
|
||||
# input item to carry both arguments and output as one item, so result
|
||||
# Contents cannot be serialized standalone. `_prepare_messages_for_openai`
|
||||
# coalesces these markers into the most recent matching `mcp_call` input
|
||||
# item before returning, dropping any that are unmatched.
|
||||
_AF_MCP_PENDING_OUTPUT_KEY = "__af_pending_mcp_result__"
|
||||
|
||||
|
||||
class OpenAIContinuationToken(ContinuationToken):
|
||||
"""Continuation token for OpenAI Responses API background operations."""
|
||||
@@ -1363,7 +1371,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
for message in chat_messages
|
||||
]
|
||||
# Flatten the list of lists into a single list
|
||||
return list(chain.from_iterable(list_of_list))
|
||||
flat = list(chain.from_iterable(list_of_list))
|
||||
# Coalesce hosted-MCP result markers onto matching mcp_call input
|
||||
# items (drop unmatched). See `_AF_MCP_PENDING_OUTPUT_KEY`.
|
||||
return self._coalesce_pending_mcp_results(flat)
|
||||
|
||||
def _prepare_message_for_openai(
|
||||
self,
|
||||
@@ -1428,6 +1439,18 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
)
|
||||
if prepared:
|
||||
all_messages.append(prepared)
|
||||
case "mcp_server_tool_call" | "mcp_server_tool_result":
|
||||
# Hosted MCP call/result contents serialize as a single
|
||||
# top-level mcp_call input item; the result side emits an
|
||||
# internal marker that `_prepare_messages_for_openai`
|
||||
# coalesces onto the matching call (or drops if unmatched).
|
||||
prepared_mcp = self._prepare_content_for_openai(
|
||||
message.role,
|
||||
content,
|
||||
replays_local_storage=replays_local_storage,
|
||||
)
|
||||
if prepared_mcp:
|
||||
all_messages.append(prepared_mcp)
|
||||
case _:
|
||||
prepared_content = self._prepare_content_for_openai(
|
||||
message.role,
|
||||
@@ -1606,6 +1629,24 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"approval_request_id": content.id,
|
||||
"approve": content.approved,
|
||||
}
|
||||
case "mcp_server_tool_call":
|
||||
if not content.call_id:
|
||||
return {}
|
||||
return {
|
||||
"type": "mcp_call",
|
||||
"id": content.call_id,
|
||||
"server_label": content.server_name or "",
|
||||
"name": content.tool_name or "",
|
||||
"arguments": self._stringify_mcp_arguments(content.arguments),
|
||||
}
|
||||
case "mcp_server_tool_result":
|
||||
if not content.call_id:
|
||||
return {}
|
||||
return {
|
||||
_AF_MCP_PENDING_OUTPUT_KEY: True,
|
||||
"call_id": content.call_id,
|
||||
"output": self._stringify_mcp_output(content.output),
|
||||
}
|
||||
case "hosted_file":
|
||||
# `input_file` is an input-only content type in the Responses API and is rejected
|
||||
# inside an assistant message. Hosted-file content on an assistant message
|
||||
@@ -1681,6 +1722,91 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"""Join shell commands into a single executable command string."""
|
||||
return "\n".join(command for command in commands if command).strip()
|
||||
|
||||
@staticmethod
|
||||
def _stringify_mcp_arguments(arguments: Any) -> str:
|
||||
"""Render hosted-MCP tool-call arguments as a JSON string for the Responses API."""
|
||||
if arguments is None:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
try:
|
||||
return json.dumps(arguments)
|
||||
except (TypeError, ValueError):
|
||||
return str(arguments)
|
||||
|
||||
@staticmethod
|
||||
def _stringify_mcp_output(output: Any) -> str:
|
||||
"""Render a hosted-MCP tool-call result into the string `mcp_call.output` field.
|
||||
|
||||
Accepts a string, a list of text-bearing Content objects (the form
|
||||
the chat client produces when parsing an `mcp_call` Responses item),
|
||||
or any other value. List entries that are dicts with the canonical
|
||||
MCP text-content shape (`{"text": "..."}`) are unwrapped to their
|
||||
text. Anything else falls back to JSON encoding rather than Python
|
||||
`repr`, so the wire payload stays parseable for downstream callers.
|
||||
"""
|
||||
if output is None:
|
||||
return ""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
|
||||
# cast is for pyright (reportUnknownVariableType); mypy considers
|
||||
# it redundant after the isinstance narrowing.
|
||||
entries = cast(Sequence[Any], output) # type: ignore[redundant-cast]
|
||||
parts: list[str] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
continue
|
||||
text = getattr(entry, "text", None)
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
continue
|
||||
if isinstance(entry, Mapping):
|
||||
mapping_text = cast(Any, entry).get("text")
|
||||
if isinstance(mapping_text, str):
|
||||
parts.append(mapping_text)
|
||||
continue
|
||||
parts.append(json.dumps(entry, default=str))
|
||||
return "".join(parts)
|
||||
return json.dumps(output, default=str)
|
||||
|
||||
@staticmethod
|
||||
def _coalesce_pending_mcp_results(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Merge pending hosted-MCP result markers onto matching mcp_call input items.
|
||||
|
||||
See `_AF_MCP_PENDING_OUTPUT_KEY`. The Responses API expects a single
|
||||
`mcp_call` input item carrying both `arguments` and `output`, so a
|
||||
result Content cannot be its own input item. Any unmatched markers
|
||||
are dropped (debug-logged); surfacing them as standalone items
|
||||
would produce the orphan `function_call_output` / `mcp_call_output`
|
||||
the API rejects.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if item.get(_AF_MCP_PENDING_OUTPUT_KEY):
|
||||
target_call_id = item.get("call_id")
|
||||
target = next(
|
||||
(
|
||||
existing
|
||||
for existing in reversed(out)
|
||||
if existing.get("type") == "mcp_call" and existing.get("id") == target_call_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is not None:
|
||||
if target.get("output") is None:
|
||||
target["output"] = item.get("output")
|
||||
else:
|
||||
logger.debug(
|
||||
"Dropping orphan mcp_server_tool_result for call_id=%s; "
|
||||
"no matching mcp_call appeared in input.",
|
||||
target_call_id,
|
||||
)
|
||||
continue
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _serialize_provider_payload(value: Any) -> Any:
|
||||
"""Convert OpenAI SDK objects into JSON-serializable Python values."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import inspect
|
||||
import json
|
||||
@@ -120,6 +121,15 @@ async def create_vector_store(
|
||||
if result.last_error is not None:
|
||||
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
|
||||
|
||||
# Wait for the vector store index to be fully searchable.
|
||||
# create_and_poll confirms file processing, but the search index is eventually consistent.
|
||||
for _ in range(10):
|
||||
vs = await client.client.vector_stores.retrieve(vector_store.id)
|
||||
if vs.file_counts.completed >= 1 and vs.file_counts.in_progress == 0:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
|
||||
|
||||
|
||||
@@ -4385,10 +4395,6 @@ async def test_integration_web_search() -> None:
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Unreliable due to OpenAI vector store indexing potential "
|
||||
"race condition. See https://github.com/microsoft/agent-framework/issues/1669"
|
||||
)
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@@ -4398,31 +4404,29 @@ async def test_integration_file_search() -> None:
|
||||
assert isinstance(openai_responses_client, SupportsChatGetResponse)
|
||||
|
||||
file_id, vector_store = await create_vector_store(openai_responses_client)
|
||||
# Use static method for file search tool
|
||||
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
|
||||
# Test that the client will use the file search tool
|
||||
response = await openai_responses_client.get_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=["What is the weather today? Do a file search to find the answer."],
|
||||
)
|
||||
],
|
||||
options={
|
||||
"tool_choice": "auto",
|
||||
"tools": [file_search_tool],
|
||||
},
|
||||
)
|
||||
try:
|
||||
# Use static method for file search tool
|
||||
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
|
||||
# Test that the client will use the file search tool
|
||||
response = await openai_responses_client.get_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=["What is the weather today? Do a file search to find the answer."],
|
||||
)
|
||||
],
|
||||
options={
|
||||
"tool_choice": "auto",
|
||||
"tools": [file_search_tool],
|
||||
},
|
||||
)
|
||||
|
||||
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
|
||||
assert "sunny" in response.text.lower()
|
||||
assert "75" in response.text
|
||||
assert "sunny" in response.text.lower()
|
||||
assert "75" in response.text
|
||||
finally:
|
||||
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Unreliable due to OpenAI vector store indexing "
|
||||
"potential race condition. See https://github.com/microsoft/agent-framework/issues/1669"
|
||||
)
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@@ -4432,35 +4436,37 @@ async def test_integration_streaming_file_search() -> None:
|
||||
assert isinstance(openai_responses_client, SupportsChatGetResponse)
|
||||
|
||||
file_id, vector_store = await create_vector_store(openai_responses_client)
|
||||
# Use static method for file search tool
|
||||
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
|
||||
# Test that the client will use the web search tool
|
||||
response = openai_responses_client.get_streaming_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=["What is the weather today? Do a file search to find the answer."],
|
||||
)
|
||||
],
|
||||
options={
|
||||
"tool_choice": "auto",
|
||||
"tools": [file_search_tool],
|
||||
},
|
||||
)
|
||||
try:
|
||||
# Use static method for file search tool
|
||||
file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
|
||||
# Test that the client will use the file search tool
|
||||
response = openai_responses_client.get_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=["What is the weather today? Do a file search to find the answer."],
|
||||
)
|
||||
],
|
||||
stream=True,
|
||||
options={
|
||||
"tool_choice": "auto",
|
||||
"tools": [file_search_tool],
|
||||
},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
full_message: str = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if content.type == "text" and content.text:
|
||||
full_message += content.text
|
||||
assert response is not None
|
||||
full_message: str = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if content.type == "text" and content.text:
|
||||
full_message += content.text
|
||||
|
||||
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
|
||||
|
||||
assert "sunny" in full_message.lower()
|
||||
assert "75" in full_message
|
||||
assert "sunny" in full_message.lower()
|
||||
assert "75" in full_message
|
||||
finally:
|
||||
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@@ -5133,4 +5139,137 @@ def test_prepare_messages_for_openai_filters_none_fc_id() -> None:
|
||||
assert fc_item["id"].startswith("fc_")
|
||||
|
||||
|
||||
# region: hosted MCP round-trip (issue #5546)
|
||||
|
||||
|
||||
def test_prepare_messages_for_openai_serializes_mcp_server_tool_call_as_mcp_call_input_item() -> None:
|
||||
"""A Message containing only an mcp_server_tool_call Content should produce
|
||||
a top-level mcp_call input item, not be silently dropped (which today's
|
||||
_prepare_content_for_openai default branch does).
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q": "cats"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result = client._prepare_messages_for_openai(messages)
|
||||
|
||||
mcp_items = [item for item in result if isinstance(item, dict) and item.get("type") == "mcp_call"]
|
||||
assert len(mcp_items) == 1, f"expected exactly one mcp_call item; got result={result}"
|
||||
item = mcp_items[0]
|
||||
assert item["id"] == "mcp_abc123"
|
||||
assert item["server_label"] == "api_specs"
|
||||
assert item["name"] == "search"
|
||||
assert item["arguments"] == '{"q": "cats"}'
|
||||
assert "output" not in item or item["output"] is None
|
||||
|
||||
|
||||
def test_prepare_messages_for_openai_coalesces_mcp_call_and_result_into_single_item() -> None:
|
||||
"""An mcp_server_tool_call followed by an mcp_server_tool_result with the
|
||||
same call_id (in same or separate Messages) must produce ONE mcp_call
|
||||
input item carrying both arguments and output. Two items would let the
|
||||
Responses API see an orphaned output and reject the request.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_abc123",
|
||||
tool_name="search",
|
||||
server_name="api_specs",
|
||||
arguments='{"q": "cats"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_abc123",
|
||||
output=[Content.from_text(text="found 10 cats")],
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result = client._prepare_messages_for_openai(messages)
|
||||
|
||||
mcp_items = [item for item in result if isinstance(item, dict) and item.get("type") == "mcp_call"]
|
||||
assert len(mcp_items) == 1, f"expected one coalesced mcp_call item carrying both arguments and output; got {result}"
|
||||
item = mcp_items[0]
|
||||
assert item["id"] == "mcp_abc123"
|
||||
assert item["arguments"] == '{"q": "cats"}'
|
||||
assert item.get("output") == "found 10 cats"
|
||||
|
||||
# And no orphaned function_call_output should appear anywhere in the input.
|
||||
fco_items = [item for item in result if isinstance(item, dict) and item.get("type") == "function_call_output"]
|
||||
assert fco_items == [], f"unexpected orphan function_call_output items: {fco_items}"
|
||||
|
||||
|
||||
def test_prepare_messages_for_openai_drops_orphan_mcp_server_tool_result() -> None:
|
||||
"""When an mcp_server_tool_result has no matching mcp_server_tool_call in
|
||||
the message list, it must be dropped, NOT serialized as a
|
||||
function_call_output. An orphan function_call_output is what triggers the
|
||||
Responses API 400 reported in #5546.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_orphan_id",
|
||||
output=[Content.from_text(text="dangling output")],
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result = client._prepare_messages_for_openai(messages)
|
||||
|
||||
fco_items = [item for item in result if isinstance(item, dict) and item.get("type") == "function_call_output"]
|
||||
assert fco_items == [], f"orphan mcp_server_tool_result must not serialize as function_call_output; got {fco_items}"
|
||||
mcp_items = [item for item in result if isinstance(item, dict) and item.get("type") == "mcp_call"]
|
||||
assert mcp_items == [], f"orphan mcp_server_tool_result must not synthesize a stand-alone mcp_call; got {mcp_items}"
|
||||
|
||||
|
||||
def test_stringify_mcp_output_extracts_text_from_dict_entries() -> None:
|
||||
"""A list of dicts in the canonical MCP text-content shape
|
||||
(`{"type": "text", "text": "..."}`, e.g. from raw-JSON-decoded MCP
|
||||
responses) must unwrap to plain text rather than Python `repr`.
|
||||
"""
|
||||
result = OpenAIChatClient._stringify_mcp_output([{"type": "text", "text": "found 10 cats"}])
|
||||
assert result == "found 10 cats"
|
||||
|
||||
|
||||
def test_stringify_mcp_output_falls_back_to_json_for_non_text_dict_entries() -> None:
|
||||
"""Dict entries that are not in the canonical text-content shape must
|
||||
serialize as JSON, not Python `repr`. Python `repr` for a dict uses
|
||||
single quotes and would not round-trip through any JSON-aware consumer.
|
||||
"""
|
||||
result = OpenAIChatClient._stringify_mcp_output([{"type": "image", "url": "https://example.com/x"}])
|
||||
# Valid JSON: starts with `{`, contains the keys, no Python-repr single quotes.
|
||||
assert result.startswith("{")
|
||||
assert '"url"' in result
|
||||
assert "'" not in result
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
@@ -77,6 +78,15 @@ async def create_vector_store(client: OpenAIChatClient) -> tuple[str, Content]:
|
||||
if result.last_error is not None:
|
||||
raise RuntimeError(f"Vector store file processing failed with status: {result.last_error.message}")
|
||||
|
||||
# Wait for the vector store index to be fully searchable.
|
||||
# create_and_poll confirms file processing, but the search index is eventually consistent.
|
||||
for _ in range(10):
|
||||
vs = await client.client.vector_stores.retrieve(vector_store.id)
|
||||
if vs.file_counts.completed >= 1 and vs.file_counts.in_progress == 0:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
|
||||
|
||||
|
||||
@@ -355,7 +365,6 @@ async def test_integration_web_search() -> None:
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.")
|
||||
async def test_integration_client_file_search() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=credential)
|
||||
@@ -381,7 +390,6 @@ async def test_integration_client_file_search() -> None:
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.")
|
||||
async def test_integration_client_file_search_streaming() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=credential)
|
||||
|
||||
@@ -24,7 +24,9 @@ Next to what happens in the code when you run, we also make setting up observabi
|
||||
|
||||
### MCP trace propagation
|
||||
|
||||
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries for all transports (stdio, HTTP, WebSocket), compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
|
||||
Whenever there is an active OpenTelemetry span context, Agent Framework automatically propagates trace context to MCP servers via the `params._meta` field of `tools/call` requests. It uses the globally-configured OpenTelemetry propagator(s) (W3C Trace Context by default, producing `traceparent` and `tracestate`), so custom propagators (B3, Jaeger, etc.) are also supported. This enables distributed tracing across agent-to-MCP-server boundaries, compliant with the [MCP `_meta` specification](https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta).
|
||||
|
||||
**Scope:** automatic `_meta` injection applies only to MCP sessions that the agent process itself opens — `MCPStreamableHTTPTool`, `MCPStdioTool`, and `MCPWebsocketTool` (or any other client-opened `MCPTool` subclass). It does **not** apply to hosted/provider-managed MCP tool configurations such as `FoundryChatClient.get_mcp_tool(...)`, `OpenAIChatClient.get_mcp_tool(...)`, `AnthropicClient.get_mcp_tool(...)`, `GeminiChatClient.get_mcp_tool(...)`, or toolbox-fetched tools (for example, `toolbox = await client.get_toolbox(...)`, then passing `toolbox.tools` into `Agent(tools=...)`), because in those cases the `tools/call` message is issued by the provider service runtime rather than by the agent process. As a result, the framework has no opportunity to inject trace context into those requests, and propagating `traceparent`/`tracestate` across that hosted-service boundary is the responsibility of the service runtime, not Agent Framework. If end-to-end distributed tracing to the downstream MCP server is required, use a client-opened MCP transport instead of a hosted connector.
|
||||
|
||||
### Five patterns for configuring observability
|
||||
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Claude Agent with Function Approval
|
||||
|
||||
This sample demonstrates how to enforce ``approval_mode="always_require"`` on a
|
||||
``FunctionTool`` when using ``ClaudeAgent``. Because the Claude Agent SDK runs
|
||||
its own tool-calling loop, the standard agent-framework approval round-trip
|
||||
(``FunctionApprovalRequestContent`` → ``FunctionApprovalResponseContent``) is
|
||||
not available — the agent instead awaits an ``on_function_approval`` callback
|
||||
inside the tool handler before executing the tool.
|
||||
|
||||
Key points:
|
||||
- ``on_function_approval`` is set on ``ClaudeAgentOptions`` and receives a
|
||||
``FunctionCallContent`` describing the pending call. It must return ``True``
|
||||
to allow execution or ``False`` to deny it. Async callbacks are also
|
||||
supported.
|
||||
- If no callback is configured, calls to ``always_require`` tools are denied
|
||||
by default and the model receives an explanatory error so it can react.
|
||||
- This callback is independent of Claude's built-in ``permission_mode`` /
|
||||
``can_use_tool`` features, which gate the SDK's own shell/file actions.
|
||||
|
||||
Environment variables:
|
||||
- ANTHROPIC_API_KEY: Your Anthropic API key.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from random import randrange
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import Content, tool
|
||||
from agent_framework.anthropic import ClaudeAgent
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# Always-require tool: execution must be gated by on_function_approval.
|
||||
@tool(approval_mode="always_require")
|
||||
def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str:
|
||||
"""Get a detailed weather report for a location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
return (
|
||||
f"The weather in {location} is {conditions[randrange(0, len(conditions))]} "
|
||||
f"with a high of {randrange(10, 30)}C and humidity of 88%."
|
||||
)
|
||||
|
||||
|
||||
def prompt_for_approval(call: Content) -> bool:
|
||||
"""Synchronous approval prompt.
|
||||
|
||||
The callback receives a ``FunctionCallContent`` so the operator can review
|
||||
the tool name and arguments before deciding. Returning ``True`` allows the
|
||||
call; returning ``False`` denies it and a tool-error is returned to the
|
||||
model.
|
||||
"""
|
||||
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = input("Approve this tool call? (y/n): ").strip().lower()
|
||||
return response in ("y", "yes")
|
||||
|
||||
|
||||
async def prompt_for_approval_async(call: Content) -> bool:
|
||||
"""Async approval prompt.
|
||||
|
||||
Use an async callback when approval requires I/O (e.g. an HTTP call to a
|
||||
review service or queueing the request to a UI). ``input()`` is wrapped
|
||||
with ``asyncio.to_thread`` so the event loop is not blocked.
|
||||
"""
|
||||
print(f"\n[Function Approval Request - async]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = await asyncio.to_thread(input, "Approve this tool call? (y/n): ")
|
||||
return response.strip().lower() in ("y", "yes")
|
||||
|
||||
|
||||
async def run_with_sync_callback() -> None:
|
||||
print("\n=== Claude Agent: synchronous approval callback ===")
|
||||
agent = ClaudeAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_function_approval": prompt_for_approval},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Seattle."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
async def run_with_async_callback() -> None:
|
||||
print("\n=== Claude Agent: asynchronous approval callback ===")
|
||||
agent = ClaudeAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_function_approval": prompt_for_approval_async},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Tokyo."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
async def run_without_callback() -> None:
|
||||
"""Default-deny demonstration.
|
||||
|
||||
With no ``on_function_approval`` configured, the always-require tool is
|
||||
refused and the model receives an explanatory error, so it can apologise
|
||||
or try a different approach instead of silently failing.
|
||||
"""
|
||||
print("\n=== Claude Agent: no callback configured (deny by default) ===")
|
||||
agent = ClaudeAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Paris."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== Claude Agent: Function approval enforcement ===")
|
||||
await run_with_sync_callback()
|
||||
await run_with_async_callback()
|
||||
await run_without_callback()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
GitHub Copilot Agent with Function Approval
|
||||
|
||||
This sample demonstrates how to enforce ``approval_mode="always_require"`` on a
|
||||
``FunctionTool`` when using ``GitHubCopilotAgent``. Because the Copilot CLI
|
||||
runs its own tool-calling loop, the standard agent-framework approval
|
||||
round-trip (``FunctionApprovalRequestContent`` → ``FunctionApprovalResponseContent``)
|
||||
is not available — the agent instead awaits an ``on_function_approval``
|
||||
callback inside the tool handler before executing the tool.
|
||||
|
||||
Key points:
|
||||
- ``on_function_approval`` is set on ``GitHubCopilotOptions`` and receives a
|
||||
``FunctionCallContent`` describing the pending call. It must return ``True``
|
||||
to allow execution or ``False`` to deny it. Async callbacks are also
|
||||
supported.
|
||||
- If no callback is configured, calls to ``always_require`` tools are denied
|
||||
by default and the model receives an explanatory error so it can react.
|
||||
- This callback is independent of ``on_permission_request``, which gates the
|
||||
Copilot SDK's *built-in* shell/file actions; ``on_function_approval`` gates
|
||||
agent-framework ``FunctionTool`` calls.
|
||||
|
||||
Environment variables (optional):
|
||||
- GITHUB_COPILOT_CLI_PATH: Path to the Copilot CLI executable.
|
||||
- GITHUB_COPILOT_MODEL: Model to use.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from random import randrange
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import Content, tool
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# Always-require tool: execution must be gated by on_function_approval.
|
||||
@tool(approval_mode="always_require")
|
||||
def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str:
|
||||
"""Get a detailed weather report for a location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
return (
|
||||
f"The weather in {location} is {conditions[randrange(0, len(conditions))]} "
|
||||
f"with a high of {randrange(10, 30)}C and humidity of 88%."
|
||||
)
|
||||
|
||||
|
||||
def prompt_for_approval(call: Content) -> bool:
|
||||
"""Synchronous approval prompt.
|
||||
|
||||
The callback receives a ``FunctionCallContent`` so the operator can review
|
||||
the tool name and arguments before deciding. Returning ``True`` allows the
|
||||
call; returning ``False`` denies it and a tool-error is returned to the
|
||||
model.
|
||||
"""
|
||||
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = input("Approve this tool call? (y/n): ").strip().lower()
|
||||
return response in ("y", "yes")
|
||||
|
||||
|
||||
async def prompt_for_approval_async(call: Content) -> bool:
|
||||
"""Async approval prompt.
|
||||
|
||||
Use an async callback when approval requires I/O (e.g. an HTTP call to a
|
||||
review service or queueing the request to a UI). ``input()`` is wrapped
|
||||
with ``asyncio.to_thread`` so the event loop is not blocked.
|
||||
"""
|
||||
print(f"\n[Function Approval Request - async]\n Tool: {call.name}\n Arguments: {call.arguments}")
|
||||
response = await asyncio.to_thread(input, "Approve this tool call? (y/n): ")
|
||||
return response.strip().lower() in ("y", "yes")
|
||||
|
||||
|
||||
async def run_with_sync_callback() -> None:
|
||||
print("\n=== GitHub Copilot Agent: synchronous approval callback ===")
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_function_approval": prompt_for_approval},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Seattle."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}")
|
||||
|
||||
|
||||
async def run_with_async_callback() -> None:
|
||||
print("\n=== GitHub Copilot Agent: asynchronous approval callback ===")
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
default_options={"on_function_approval": prompt_for_approval_async},
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Tokyo."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}")
|
||||
|
||||
|
||||
async def run_without_callback() -> None:
|
||||
"""Default-deny demonstration.
|
||||
|
||||
With no ``on_function_approval`` configured, the always-require tool is
|
||||
refused and the model receives an explanatory error, so it can apologise
|
||||
or try a different approach instead of silently failing.
|
||||
"""
|
||||
print("\n=== GitHub Copilot Agent: no callback configured (deny by default) ===")
|
||||
agent = GitHubCopilotAgent(
|
||||
instructions="You are a helpful weather assistant.",
|
||||
tools=[get_weather_detail],
|
||||
)
|
||||
async with agent:
|
||||
query = "Give me the detailed weather for Paris."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== GitHub Copilot Agent: Function approval enforcement ===")
|
||||
await run_with_sync_callback()
|
||||
await run_with_async_callback()
|
||||
await run_without_callback()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -55,7 +55,7 @@ Write workflows as plain Python async functions — no graph concepts, no execut
|
||||
| Workflow as Agent (Reflection Pattern) | [agents/workflow_as_agent_reflection_pattern.py](./agents/workflow_as_agent_reflection_pattern.py) | Wrap a workflow so it can behave like an agent (reflection pattern) |
|
||||
| Workflow as Agent + HITL | [agents/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability |
|
||||
| Workflow as Agent with Session | [agents/workflow_as_agent_with_session.py](./agents/workflow_as_agent_with_session.py) | Use AgentSession to maintain conversation history across workflow-as-agent invocations |
|
||||
| Workflow as Agent kwargs | [agents/workflow_as_agent_kwargs.py](./agents/workflow_as_agent_kwargs.py) | Pass custom context (data, user tokens) via kwargs through workflow.as_agent() to @ai_function tools |
|
||||
| Workflow as Agent kwargs | [agents/workflow_as_agent_kwargs.py](./agents/workflow_as_agent_kwargs.py) | Pass custom context (data, user tokens) via kwargs through workflow.as_agent() to @tool tools |
|
||||
|
||||
### checkpoint
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Invoke HTTP Request sample - demonstrates the HttpRequestAction declarative action.
|
||||
|
||||
This sample shows how to:
|
||||
1. Configure a ``WorkflowFactory`` with a ``HttpRequestHandler`` so the YAML
|
||||
``HttpRequestAction`` can dispatch real HTTP calls.
|
||||
2. Fetch JSON from a public REST endpoint (the GitHub repository API) and
|
||||
bind the parsed response to a workflow variable.
|
||||
3. Mirror the response body into the conversation via ``conversationId`` so
|
||||
a downstream Foundry agent can answer questions about it using only that
|
||||
conversation context.
|
||||
|
||||
Security note:
|
||||
``DefaultHttpRequestHandler`` issues HTTP calls to whatever URL the
|
||||
workflow author specifies and performs **no** allowlisting or SSRF
|
||||
guards. For production use, replace it with a custom handler that
|
||||
enforces an allowlist or DNS-rebinding-resistant policy and adds any
|
||||
required authentication headers per call.
|
||||
|
||||
Run with:
|
||||
python -m samples.03-workflows.declarative.invoke_http_request.main
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.declarative import (
|
||||
DefaultHttpRequestHandler,
|
||||
WorkflowFactory,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
GITHUB_REPO_INFO_AGENT_INSTRUCTIONS = """\
|
||||
You answer the user's question about a GitHub repository using ONLY the JSON
|
||||
data already present in the conversation history. If the answer is not
|
||||
contained in the conversation, say so plainly rather than guessing. Be concise
|
||||
and helpful.
|
||||
"""
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the invoke HTTP request workflow."""
|
||||
chat_client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# The agent has no tools — it answers the question about the GitHub
|
||||
# repository using only the JSON data that ``HttpRequestAction`` adds to
|
||||
# the conversation.
|
||||
github_repo_info_agent = Agent(
|
||||
client=chat_client,
|
||||
name="GitHubRepoInfoAgent",
|
||||
instructions=GITHUB_REPO_INFO_AGENT_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
agents = {"GitHubRepoInfoAgent": github_repo_info_agent}
|
||||
|
||||
# The default HttpRequestHandler is sufficient for this sample because
|
||||
# the GitHub REST endpoint used here does not require authentication.
|
||||
# For authenticated endpoints, supply a custom client_provider callback
|
||||
# to DefaultHttpRequestHandler so each request can be routed through a
|
||||
# pre-configured httpx.AsyncClient with the appropriate credentials.
|
||||
async with DefaultHttpRequestHandler() as http_handler:
|
||||
factory = WorkflowFactory(
|
||||
agents=agents,
|
||||
http_request_handler=http_handler,
|
||||
)
|
||||
|
||||
workflow_path = Path(__file__).parent / "workflow.yaml"
|
||||
workflow = factory.create_workflow_from_yaml_path(workflow_path)
|
||||
|
||||
print("=" * 60)
|
||||
print("Invoke HTTP Request Workflow Demo")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Ask one question about the microsoft/agent-framework repo.")
|
||||
print()
|
||||
|
||||
user_input = input("You: ").strip() # noqa: ASYNC250
|
||||
if not user_input:
|
||||
user_input = "Please summarize the repository."
|
||||
|
||||
print("\nAgent: ", end="", flush=True)
|
||||
async for event in workflow.run(user_input, stream=True):
|
||||
if event.type == "output" and isinstance(event.data, str):
|
||||
print(event.data, end="", flush=True)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
#
|
||||
# This workflow demonstrates the HttpRequestAction declarative action.
|
||||
#
|
||||
# HttpRequestAction lets a workflow author issue an HTTP call directly from
|
||||
# YAML without writing any Python glue. It can:
|
||||
#
|
||||
# - fetch data from external REST endpoints,
|
||||
# - store the parsed response in a workflow variable, and
|
||||
# - add the response body to the conversation so a downstream agent can
|
||||
# answer questions based on it.
|
||||
#
|
||||
# This sample fetches public metadata for the microsoft/agent-framework
|
||||
# repository from the GitHub REST API (no authentication required) and uses
|
||||
# a Foundry agent to answer a single question about it.
|
||||
#
|
||||
# Example input:
|
||||
# How many open issues does the repository have?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_http_request_demo
|
||||
actions:
|
||||
|
||||
# Set the repository org/name used to form the request URL.
|
||||
- kind: SetVariable
|
||||
id: set_repo_name
|
||||
variable: Local.RepoName
|
||||
value: microsoft/agent-framework
|
||||
|
||||
# Invoke the GitHub repo API. The response body is parsed into
|
||||
# Local.RepoInfo and also added to the conversation (via conversationId)
|
||||
# so the agent below can answer questions based on it.
|
||||
- kind: HttpRequestAction
|
||||
id: fetch_repo_info
|
||||
conversationId: =System.ConversationId
|
||||
method: GET
|
||||
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
|
||||
headers:
|
||||
Accept: application/vnd.github+json
|
||||
User-Agent: agent-framework-sample
|
||||
response: Local.RepoInfo
|
||||
|
||||
# Use the agent to answer the user's question using the conversation
|
||||
# context (which now contains the GitHub JSON response). The user's
|
||||
# original message is already in the conversation as System.LastMessage,
|
||||
# and the executor's input fallback chain extracts its ``Text`` field
|
||||
# automatically when ``input.messages`` is omitted.
|
||||
- kind: InvokeAzureAgent
|
||||
id: answer_question
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: GitHubRepoInfoAgent
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.AgentResponse
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Invoke MCP Tool sample - demonstrates the InvokeMcpTool declarative action.
|
||||
|
||||
This sample shows how to:
|
||||
1. Configure a ``WorkflowFactory`` with a ``MCPToolHandler`` so the YAML
|
||||
``InvokeMcpTool`` action can dispatch real MCP tool calls.
|
||||
2. Invoke a tool on a public unauthenticated MCP server (the Microsoft
|
||||
Learn Docs MCP server at ``https://learn.microsoft.com/api/mcp``,
|
||||
calling ``microsoft_docs_search``).
|
||||
3. Bind the parsed tool result to a workflow variable and mirror it into
|
||||
the conversation via ``conversationId`` so a downstream Foundry agent
|
||||
can answer questions using only that context.
|
||||
4. Optionally pause the MCP tool call for human approval. The YAML reads
|
||||
``requireApproval`` from ``Workflow.Inputs.requireApproval`` so the
|
||||
host can flip the behaviour without editing the workflow definition.
|
||||
Set the ``MCP_REQUIRE_APPROVAL`` environment variable (``1`` / ``true``
|
||||
/ ``yes``) to enable the approval flow; leave it unset for the
|
||||
"fire-and-forget" default.
|
||||
|
||||
Security note:
|
||||
``DefaultMCPToolHandler`` connects to whatever MCP server URL the
|
||||
workflow author specifies and performs **no** allowlisting or SSRF
|
||||
guards. For production use, replace it with a custom handler that
|
||||
enforces an allowlist and adds any required authentication headers
|
||||
per server. MCP tool outputs flow back into agent conversations and
|
||||
therefore share the same prompt-injection risk surface as
|
||||
``HttpRequestAction``: only invoke MCP servers you trust.
|
||||
|
||||
The approval flow is also a defence-in-depth control: even with a
|
||||
trusted server, requiring human approval lets a reviewer inspect
|
||||
tool name, arguments, and outbound header NAMES (never values)
|
||||
before any network call is made.
|
||||
|
||||
Run with:
|
||||
python samples/03-workflows/declarative/invoke_mcp_tool/main.py
|
||||
|
||||
Run with approval prompts:
|
||||
MCP_REQUIRE_APPROVAL=1 python -m samples.03-workflows.declarative.invoke_mcp_tool.main
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.declarative import (
|
||||
DefaultMCPToolHandler,
|
||||
MCPToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
DOCS_AGENT_INSTRUCTIONS = """\
|
||||
You answer the user's question about Microsoft technology using ONLY the
|
||||
search results already present in the conversation history. If the answer is
|
||||
not contained in the conversation, say so plainly rather than guessing. Be
|
||||
concise and cite the relevant document title or URL when possible.
|
||||
"""
|
||||
|
||||
_TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _read_require_approval_flag() -> bool:
|
||||
"""Return True when the MCP_REQUIRE_APPROVAL env var requests approval."""
|
||||
return os.environ.get("MCP_REQUIRE_APPROVAL", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def _prompt_for_approval(request: MCPToolApprovalRequest) -> ToolApprovalResponse:
|
||||
"""Render the pending MCP call to stdout and read approve/reject from the user."""
|
||||
print()
|
||||
print("-" * 60)
|
||||
print("MCP tool approval required")
|
||||
print("-" * 60)
|
||||
print(f" tool: {request.tool_name}")
|
||||
print(f" server label: {request.server_label or '(unset)'}")
|
||||
print(f" server url: {request.server_url}")
|
||||
if request.arguments:
|
||||
print(" arguments:")
|
||||
for key, value in request.arguments.items():
|
||||
print(f" {key}: {value!r}")
|
||||
if request.header_names:
|
||||
# Only NAMES are surfaced; values are intentionally withheld because
|
||||
# they typically carry authentication secrets.
|
||||
print(f" outbound header names: {', '.join(request.header_names)}")
|
||||
else:
|
||||
print(" outbound header names: (none)")
|
||||
print("-" * 60)
|
||||
|
||||
while True:
|
||||
answer = input("Approve this MCP call? [y/N] ").strip().lower() # noqa: ASYNC250
|
||||
if answer in {"y", "yes"}:
|
||||
return ToolApprovalResponse(approved=True)
|
||||
if answer in {"", "n", "no"}:
|
||||
reason = input("Reason for rejection (optional): ").strip() # noqa: ASYNC250
|
||||
return ToolApprovalResponse(approved=False, reason=reason or None)
|
||||
print("Please answer 'y' or 'n'.")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the invoke MCP tool workflow."""
|
||||
chat_client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# The agent has no tools — it answers using only the search results that
|
||||
# ``InvokeMcpTool`` adds to the conversation.
|
||||
docs_agent = Agent(
|
||||
client=chat_client,
|
||||
name="DocsAgent",
|
||||
instructions=DOCS_AGENT_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
agents = {"DocsAgent": docs_agent}
|
||||
|
||||
require_approval = _read_require_approval_flag()
|
||||
|
||||
# The default MCPToolHandler is sufficient for this sample because the
|
||||
# Microsoft Learn Docs MCP server is public and unauthenticated. For
|
||||
# authenticated servers, supply a ``client_provider`` callback to route
|
||||
# requests through a pre-configured ``httpx.AsyncClient`` carrying the
|
||||
# appropriate credentials, or wrap the handler with one that injects
|
||||
# headers per call.
|
||||
async with DefaultMCPToolHandler() as mcp_handler:
|
||||
factory = WorkflowFactory(
|
||||
agents=agents,
|
||||
mcp_tool_handler=mcp_handler,
|
||||
)
|
||||
|
||||
workflow_path = Path(__file__).parent / "workflow.yaml"
|
||||
workflow = factory.create_workflow_from_yaml_path(workflow_path)
|
||||
|
||||
print("=" * 60)
|
||||
print("Invoke MCP Tool Workflow Demo")
|
||||
if require_approval:
|
||||
print("(MCP_REQUIRE_APPROVAL is set — you will be prompted before the tool runs)")
|
||||
else:
|
||||
print("(set MCP_REQUIRE_APPROVAL=1 to enable the human-approval flow)")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Ask one question that can be answered from the Microsoft Learn docs or provide a keyword to search.")
|
||||
print()
|
||||
|
||||
user_input = input("You: ").strip() # noqa: ASYNC250
|
||||
if not user_input:
|
||||
user_input = "What is the Agent Framework declarative workflow runtime?"
|
||||
|
||||
# Drive the workflow via dict-shaped inputs so the YAML can read
|
||||
# both the user's question (``Workflow.Inputs.text``) and the
|
||||
# approval toggle (``Workflow.Inputs.requireApproval``) without
|
||||
# any Python-side mutation of the workflow definition.
|
||||
workflow_inputs: dict[str, object] = {
|
||||
"text": user_input,
|
||||
"requireApproval": require_approval,
|
||||
}
|
||||
|
||||
# The request_info loop below handles the MCP approval flow when
|
||||
# the YAML requests it. When ``requireApproval`` is false the
|
||||
# workflow never emits an ``MCPToolApprovalRequest`` event, so
|
||||
# the loop runs exactly once and exits cleanly — both modes share
|
||||
# the same code path.
|
||||
pending: tuple[str, MCPToolApprovalRequest] | None = None
|
||||
produced_output = False
|
||||
printed_agent_prefix = False
|
||||
|
||||
while True:
|
||||
if pending is None:
|
||||
stream = workflow.run(workflow_inputs, stream=True)
|
||||
else:
|
||||
pending_id, pending_request = pending
|
||||
response = _prompt_for_approval(pending_request)
|
||||
stream = workflow.run(stream=True, responses={pending_id: response})
|
||||
pending = None
|
||||
|
||||
async for event in stream:
|
||||
if event.type == "output" and isinstance(event.data, str):
|
||||
if not printed_agent_prefix:
|
||||
print("\nAgent: ", end="", flush=True)
|
||||
printed_agent_prefix = True
|
||||
print(event.data, end="", flush=True)
|
||||
produced_output = True
|
||||
elif event.type == "request_info" and isinstance(event.data, MCPToolApprovalRequest):
|
||||
pending = (event.request_id, event.data)
|
||||
|
||||
if pending is None:
|
||||
if not produced_output:
|
||||
# Workflow finished without producing any agent output
|
||||
# (e.g. the user rejected the MCP tool call and the
|
||||
# downstream agent had nothing to summarise).
|
||||
print("\n(no response produced)")
|
||||
else:
|
||||
print()
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#
|
||||
# This workflow demonstrates the InvokeMcpTool declarative action.
|
||||
#
|
||||
# InvokeMcpTool lets a workflow author call a tool exposed by a Model Context
|
||||
# Protocol (MCP) server directly from YAML without writing any Python glue.
|
||||
# It can:
|
||||
#
|
||||
# - dispatch a tool call against an MCP server (with optional auth headers),
|
||||
# - store the parsed tool result in a workflow variable, and
|
||||
# - add the result to the conversation so a downstream agent can answer
|
||||
# questions based on it.
|
||||
#
|
||||
# This sample calls ``microsoft_docs_search`` on the public Microsoft Learn
|
||||
# Docs MCP server (no authentication required) and uses a Foundry agent to
|
||||
# answer a single question about Microsoft technology using the search
|
||||
# results.
|
||||
#
|
||||
# Example inputs (Choose one or provide yours):
|
||||
# How do I configure logging in the Agent Framework?
|
||||
# Gpt-5.4-mini
|
||||
#
|
||||
# Workflow inputs (set by the host via ``workflow.run({...})``):
|
||||
# text: The user's question (required).
|
||||
# requireApproval: Optional bool. When true, the MCP tool call pauses for
|
||||
# human approval before contacting the server. Defaults
|
||||
# to false when omitted.
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_mcp_tool_demo
|
||||
actions:
|
||||
|
||||
# Capture the user's question into a local variable so the MCP tool call
|
||||
# can pass it as an argument.
|
||||
- kind: SetVariable
|
||||
id: capture_query
|
||||
variable: Local.SearchQuery
|
||||
value: =Workflow.Inputs.text
|
||||
|
||||
# Invoke microsoft_docs_search on the Microsoft Learn Docs MCP server.
|
||||
# The result is parsed into Local.SearchResults and also added to the
|
||||
# conversation (via conversationId) so the agent below can answer the
|
||||
# user's question based on it.
|
||||
#
|
||||
# ``requireApproval`` reads from Workflow.Inputs so the host can toggle
|
||||
# the human-approval flow without editing this YAML. When the input is
|
||||
# absent or evaluates to a falsy value, the tool runs without pausing.
|
||||
- kind: InvokeMcpTool
|
||||
id: search_docs
|
||||
conversationId: =System.ConversationId
|
||||
serverUrl: https://learn.microsoft.com/api/mcp
|
||||
serverLabel: MicrosoftLearnDocs
|
||||
toolName: microsoft_docs_search
|
||||
requireApproval: =Workflow.Inputs.requireApproval
|
||||
arguments:
|
||||
query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: false
|
||||
result: Local.SearchResults
|
||||
|
||||
# Use the agent to answer the user's question using the conversation
|
||||
# context (which now contains the MCP search results). The user's
|
||||
# question is supplied via ``input.messages`` (sourced from the workflow
|
||||
# inputs), and the prior conversation history is bound via
|
||||
# ``conversationId``.
|
||||
- kind: InvokeAzureAgent
|
||||
id: answer_question
|
||||
conversationId: =System.ConversationId
|
||||
agent:
|
||||
name: DocsAgent
|
||||
input:
|
||||
messages: =Workflow.Inputs.text
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.AgentResponse
|
||||
@@ -13,3 +13,6 @@
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
|
||||
# Redis client with asyncio support (used by redis_stream_response_handler.py)
|
||||
redis[asyncio]
|
||||
|
||||
@@ -363,7 +363,7 @@ def _create_workflow() -> Workflow:
|
||||
|
||||
chat_client = OpenAIChatCompletionClient(
|
||||
model=os.environ["AZURE_OPENAI_MODEL"],
|
||||
api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"),
|
||||
credential=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"),
|
||||
)
|
||||
|
||||
# Create agents for parallel analysis
|
||||
|
||||
@@ -19,18 +19,22 @@ All of these samples are set up to run in Azure Functions. Azure Functions has a
|
||||
|
||||
### 2. Create and activate a virtual environment
|
||||
|
||||
Using [uv](https://docs.astral.sh/uv/) (recommended):
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
uv venv .venv
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
python -m venv .venv
|
||||
uv venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
> **Note:** `python -m venv .venv` also works, but can hang indefinitely on Windows with Microsoft Store Python due to a known `ensurepip` issue. Use `uv venv .venv` to avoid this.
|
||||
|
||||
### 3. Running the samples
|
||||
|
||||
- [Start the Azurite emulator](https://learn.microsoft.com/en-us/azure/storage/common/storage-install-azurite?tabs=npm%2Cblob-storage#run-azurite)
|
||||
|
||||
@@ -12,3 +12,6 @@
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
|
||||
# Redis client with asyncio support (used by redis_stream_response_handler.py)
|
||||
redis[asyncio]
|
||||
|
||||
+2
-2
@@ -70,7 +70,7 @@ def create_spam_agent() -> "Agent":
|
||||
return Agent(
|
||||
client=OpenAIChatCompletionClient(
|
||||
model=os.environ["AZURE_OPENAI_MODEL"],
|
||||
api_key=get_async_bearer_token_provider(
|
||||
credential=get_async_bearer_token_provider(
|
||||
AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default"
|
||||
),
|
||||
),
|
||||
@@ -88,7 +88,7 @@ def create_email_agent() -> "Agent":
|
||||
return Agent(
|
||||
client=OpenAIChatCompletionClient(
|
||||
model=os.environ["AZURE_OPENAI_MODEL"],
|
||||
api_key=get_async_bearer_token_provider(
|
||||
credential=get_async_bearer_token_provider(
|
||||
AsyncAzureCliCredential(), "https://cognitiveservices.azure.com/.default"
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,8 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew
|
||||
| 3 | [MCP](responses/03_mcp/) | An agent connected to a remote MCP server (GitHub), demonstrating external MCP tool provider integration. |
|
||||
| 4 | [Foundry Toolbox](responses/04_foundry_toolbox/) | An agent using Azure Foundry Toolbox, demonstrating toolbox provisioning and querying available tools at runtime. |
|
||||
| 5 | [Workflows](responses/05_workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. |
|
||||
| 6 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
|
||||
| 6 | [Files](responses/06_files/) | An agent demonstrating how to work with files in a hosted agent session, including uploading files to a hosted agent session and having the agent read and manipulate those files at runtime. |
|
||||
| 7 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
|
||||
|
||||
### Invocations API
|
||||
|
||||
@@ -133,18 +134,25 @@ cd agent-framework/python/samples/04-hosting/foundry-hosted-agents/responses
|
||||
|
||||
#### Environment setup
|
||||
|
||||
1. Navigate to the sample directory you want to explore. Create a virtual environment:
|
||||
1. Navigate to the sample directory you want to explore. Create and activate a virtual environment using [uv](https://docs.astral.sh/uv/) (recommended):
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
uv venv .venv
|
||||
```
|
||||
|
||||
# Windows
|
||||
.venv\Scripts\Activate
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
.venv\Scripts\Activate.ps1
|
||||
|
||||
# Windows (Command Prompt)
|
||||
.venv\Scripts\activate.bat
|
||||
|
||||
# macOS/Linux
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
> **Note:** `python -m venv .venv` also works, but can hang indefinitely on Windows with Microsoft Store Python due to a known `ensurepip` issue. Use `uv venv .venv` to avoid this.
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,6 +14,10 @@ See [main.py](main.py) for the full implementation.
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
@@ -6,4 +6,7 @@ protocols:
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: '0.25'
|
||||
memory: '0.5Gi'
|
||||
memory: '0.5Gi'
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
@@ -5,4 +5,7 @@ protocols:
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
@@ -25,7 +25,7 @@ def get_weather(
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
@tool(approval_mode="never_require")
|
||||
def run_bash(command: str) -> str:
|
||||
"""Execute a shell command locally and return stdout, stderr, and exit code."""
|
||||
try:
|
||||
|
||||
@@ -7,5 +7,7 @@ resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: GITHUB_PAT
|
||||
value: ${GITHUB_PAT}
|
||||
+12
-2
@@ -16,8 +16,6 @@ You can also create a Foundry Toolbox in the Foundry portal. Read more about it
|
||||
|
||||
The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI-compatible Responses client. It loads a named Foundry Toolbox via `client.get_toolbox(name)` — the toolbox is a server-side bundle of tool configurations (e.g., `code_interpreter`, `web_search`) defined in the Foundry portal or by `azd provision`. Omitting `version` resolves the toolbox's current default version at runtime.
|
||||
|
||||
The sample then narrows the toolbox to a subset of tool types via `select_toolbox_tools(toolbox, include_types=[...])` before handing it to the agent. This demonstrates how one toolbox can be reused across agents that each expose only the tools they need — here, the agent only sees `code_interpreter` even though the toolbox also includes `web_search`.
|
||||
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
### Agent Hosting
|
||||
@@ -28,6 +26,18 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age
|
||||
|
||||
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
|
||||
|
||||
An extra environment variable `TOOLBOX_NAME` must be set to the name of the Foundry Toolbox that the agent should load at runtime. This allows the agent host to dynamically retrieve the correct toolbox from Foundry when it starts. Run the following:
|
||||
|
||||
```bash
|
||||
export TOOLBOX_NAME="<your-toolbox-name>"
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:TOOLBOX_NAME="<your-toolbox-name>"
|
||||
```
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
+6
-1
@@ -5,4 +5,9 @@ protocols:
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
- name: TOOLBOX_NAME
|
||||
value: "agent-tools"
|
||||
@@ -5,4 +5,7 @@ protocols:
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
@@ -0,0 +1,9 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Local-only client tooling and sample data; not needed inside the agent image.
|
||||
resources/
|
||||
@@ -0,0 +1,3 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
TOOLBOX_NAME="..."
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . user_agent/
|
||||
WORKDIR /app/user_agent
|
||||
|
||||
RUN if [ -f requirements.txt ]; then \
|
||||
pip install -r requirements.txt; \
|
||||
else \
|
||||
echo "No requirements.txt found"; \
|
||||
fi
|
||||
|
||||
EXPOSE 8088
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,115 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
An [Agent Framework](https://github.com/microsoft/agent-framework) agent that uses a local shell tool and a code interpreter tool for working with files, and hosted using the **Responses protocol**.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Model Integration
|
||||
|
||||
The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes.
|
||||
|
||||
See [main.py](main.py) for the full implementation.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
|
||||
|
||||
### Tools
|
||||
|
||||
This agent uses four tools:
|
||||
|
||||
1. **Get Current Working Directory Tool (`get_cwd`)** – Returns the current working directory of the agent host process.
|
||||
2. **List Files Tool (`list_files`)** – Lists the files in a specified directory.
|
||||
3. **Read File Tool (`read_file`)** – Reads the contents of a specified file.
|
||||
4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe.
|
||||
|
||||
> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool is a managed tool provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../04_foundry_toolbox/).
|
||||
|
||||
## Running the Agent Host
|
||||
|
||||
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
|
||||
|
||||
An extra environment variable `TOOLBOX_NAME` must be set to the name of the Foundry Toolbox that the agent should load at runtime. This allows the agent host to dynamically retrieve the correct toolbox from Foundry when it starts. Run the following:
|
||||
|
||||
```bash
|
||||
export TOOLBOX_NAME="<your-toolbox-name>"
|
||||
```
|
||||
|
||||
Or in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:TOOLBOX_NAME="<your-toolbox-name>"
|
||||
```
|
||||
|
||||
## Interacting with the agent
|
||||
|
||||
> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent.
|
||||
|
||||
Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Find the quarterly report under `{cwd}/resources` and tell me the difference of revenue between q1 2026 and q1 2025?"}'
|
||||
```
|
||||
|
||||
> When ruuning locally, it runs within the project directory, which contains the entire sample, so the `{cwd}/resources` path in the query above will allow the agent to locate the `resources` folder included with this sample and read the `contoso_q1_2026_report.txt` file from that folder.
|
||||
|
||||
The server will respond with a JSON object containing the response text and a response ID. You can use this response ID to continue the conversation in subsequent requests.
|
||||
|
||||
## Deploying the Agent to Foundry
|
||||
|
||||
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
|
||||
|
||||
## Uploading a file to a session
|
||||
|
||||
Deploying the agent won't automatically upload the files included with this sample to Foundry. To make these files available to the agent at runtime, you must upload them to a [hosted agent session](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions). Files are tied to a specific hosted agent session, so each time you start a new session you will need to upload the files again if the agent needs access to them during that session.
|
||||
|
||||
After you deploy the agent to Foundry, you have two ways to interact with the agent:
|
||||
|
||||
1. Using `azd ai agent invoke`.
|
||||
2. Through the Foundry portal.
|
||||
|
||||
### Using `azd ai agent invoke`
|
||||
|
||||
After successfully deploying the agent to Foundry, run the following command:
|
||||
|
||||
> You must remain in the directory where your `azd` project is initialized so that the CLI can locate the deployed agent configuration.
|
||||
|
||||
```bash
|
||||
azd ai agent invoke "Hi!"
|
||||
```
|
||||
|
||||
The command will invoke the agent and the server will create a new session if one does not already exist for this interaction, returning the agent's response from the hosted agent session. Run the following if you want to force a new session:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --new-session "Hi!"
|
||||
```
|
||||
|
||||
Run the following command to upload a file to the hosted agent session:
|
||||
|
||||
```bash
|
||||
azd ai agent files upload -f <path-to-contoso_q1_2026_report.txt>
|
||||
```
|
||||
|
||||
> The above command will automatically detect the last active session and upload the file to that session without requiring you to explicitly provide a session ID. It is also possible to specify a particular session ID to upload the file to a specific hosted agent session by using the `--session-id` flag. Run `azd ai agent files upload -h` to see the full list of options and flags available for the `upload` command.
|
||||
|
||||
Once the file is uploaded to the hosted agent session, the agent will be able to access it during that session and use it to respond to queries that reference the uploaded file.
|
||||
|
||||
Invoke the agent again with a query that references the uploaded file to see how it can now use the file in its responses. For example:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke "Find the quarterly report under the home directory and tell me the difference of revenue between q1 2026 and q1 2025?"
|
||||
```
|
||||
|
||||
### Using the Foundry Portal
|
||||
|
||||
Similar to using the `azd` CLI, you must invoke the agent first to create a session:
|
||||
|
||||

|
||||
|
||||
Once the session is created, you can grab the session ID and use `azd ai agent files upload --session-id <session-id>` to upload files to that specific hosted agent session.
|
||||
|
||||

|
||||
|
||||
Or you can upload files directly through the Foundry portal by navigating to Files tab in the agent playground:
|
||||
|
||||

|
||||
+32
@@ -0,0 +1,32 @@
|
||||
name: agent-framework-agent-files-responses
|
||||
description: >
|
||||
An Agent Framework agent that can work with files hosted by Foundry.
|
||||
metadata:
|
||||
tags:
|
||||
- Agent Framework
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
template:
|
||||
name: agent-framework-agent-files-responses
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: TOOLBOX_NAME
|
||||
value: "agent-tools"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
- kind: toolbox
|
||||
name: agent-tools
|
||||
tools:
|
||||
- type: web_search
|
||||
name: web_search
|
||||
- type: code_interpreter
|
||||
name: code_interpreter
|
||||
@@ -0,0 +1,12 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: agent-framework-agent-files-responses
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: '0.25'
|
||||
memory: '0.5Gi'
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework_foundry import select_toolbox_tools
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@tool(description="Get the current working directory.", approval_mode="never_require")
|
||||
def get_cwd() -> str:
|
||||
"""Get the current working directory."""
|
||||
try:
|
||||
return os.getcwd()
|
||||
except Exception as e:
|
||||
return f"Error getting current working directory: {e}"
|
||||
|
||||
|
||||
@tool(description="List files in a directory.", approval_mode="never_require")
|
||||
def list_files(directory: str) -> list[str]:
|
||||
"""List files in a directory."""
|
||||
try:
|
||||
return os.listdir(directory)
|
||||
except Exception as e:
|
||||
return [f"Error listing files in {directory}: {e}"]
|
||||
|
||||
|
||||
@tool(description="Read the contents of a file.", approval_mode="never_require")
|
||||
def read_file(file_path: str) -> str:
|
||||
"""Read the contents of a file."""
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
return f"Error reading file {file_path}: {e}"
|
||||
|
||||
|
||||
async def main():
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
# Load the named toolbox from the Foundry project. Omitting `version`
|
||||
# resolves the toolbox's current default version at runtime.
|
||||
toolbox = await client.get_toolbox(os.environ["TOOLBOX_NAME"])
|
||||
# The toolbox deployed has two tools: (see agent.manifest.yaml)
|
||||
# - `code_interpreter`
|
||||
# - `web_search`
|
||||
# We only need the `code_interpreter` tool for this sample
|
||||
selected_tools = select_toolbox_tools(
|
||||
toolbox,
|
||||
include_names=["code_interpreter"],
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
instructions=(
|
||||
"You are a friendly assistant. Keep your answers brief. "
|
||||
"Make sure all mathematical calculations are performed using the code interpreter "
|
||||
"instead of mental arithmetic."
|
||||
),
|
||||
tools=[get_cwd, list_files, read_file] + selected_tools,
|
||||
# History will be managed by the hosting infrastructure, thus there
|
||||
# is no need to store history by the service. Learn more at:
|
||||
# https://developers.openai.com/api/reference/resources/responses/methods/create
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
server = ResponsesHostServer(agent)
|
||||
await server.run_async()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user