mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021d9f2dc4 | ||
|
|
a47da4de3d | ||
|
|
0681e046ea | ||
|
|
05c29347ff | ||
|
|
0987ce3d31 | ||
|
|
4ad96b64e7 | ||
|
|
e3875f2c91 | ||
|
|
9199c84d42 | ||
|
|
0bbedc4fa2 | ||
|
|
18d7a46a54 | ||
|
|
9d8c3f8cb7 | ||
|
|
9faf52de4f | ||
|
|
d2ce0e9087 | ||
|
|
0557b5782b | ||
|
|
eb709d8fc9 | ||
|
|
226c004b53 | ||
|
|
3aae3cb9de |
@@ -60,6 +60,7 @@ jobs:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/**'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/**'
|
||||
- 'dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/**'
|
||||
- 'dotnet/Directory.Packages.props'
|
||||
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
|
||||
- '.github/workflows/dotnet-build-and-test.yml'
|
||||
@@ -340,7 +341,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
targetFramework: net10.0
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -357,31 +357,15 @@ jobs:
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
|
||||
- name: Generate test solution (no samples)
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/agent-framework-dotnet.slnx `
|
||||
-TargetFramework $env:targetFramework `
|
||||
-Configuration $env:configuration `
|
||||
-ExcludeSamples `
|
||||
-OutputPath dotnet/filtered.slnx `
|
||||
-Verbose
|
||||
|
||||
- name: Generate Foundry hosted IT filtered solution
|
||||
shell: pwsh
|
||||
run: |
|
||||
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
|
||||
-Solution dotnet/filtered.slnx `
|
||||
-TargetFramework $env:targetFramework `
|
||||
-Configuration $env:configuration `
|
||||
-TestProjectNameFilter "Foundry.Hosting.IntegrationTests*" `
|
||||
-OutputPath dotnet/filtered-foundry-hosted.slnx `
|
||||
-Verbose
|
||||
|
||||
# Build the test csproj directly instead of a filtered slnx + -f override.
|
||||
# The test project pins TargetFrameworks=net10.0 and its ProjectReference closure
|
||||
# gives MSBuild a single-rooted graph, so each multi-targeted dependency is invoked
|
||||
# exactly once for net10.0. This avoids the MSB3026/MSB3491/MSB4018/MSB3883 file-lock
|
||||
# collisions caused by parallel inner-builds racing on shared bin/obj output paths
|
||||
# under the previous slnx + global TFM override approach.
|
||||
- name: Build Foundry hosted IT (and its deps)
|
||||
shell: bash
|
||||
run: dotnet build dotnet/filtered-foundry-hosted.slnx -c "$configuration" -f "$targetFramework" --warnaserror
|
||||
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
|
||||
|
||||
- name: Azure CLI Login
|
||||
uses: azure/login@v2
|
||||
@@ -394,13 +378,12 @@ jobs:
|
||||
# are picked up; the image tag is content-hashed across the test container source AND its
|
||||
# framework project references, so identical content is a no-op push.
|
||||
#
|
||||
# `-UsePrebuiltProjectReferences` opts into the no-rebuild fast path: publish skips
|
||||
# rebuilding ProjectReferences and consumes the DLLs the prior "Build Foundry hosted IT
|
||||
# (and its deps)" step already produced. This avoids MSB3026 ("file is being used by
|
||||
# another process") collisions caused by the previous build's shared-compilation server
|
||||
# still holding file handles to those DLLs. Safe in CI because the prebuild step ran in
|
||||
# the same job against the same source. Do not remove the prebuild step (the subsequent
|
||||
# `dotnet test --no-build` step depends on it too).
|
||||
# The script always passes --no-dependencies to dotnet publish so publish never re-touches
|
||||
# the framework lib DLLs the prior "Build Foundry hosted IT (and its deps)" step produced.
|
||||
# This structurally eliminates the MSB3026 collision that VBCSCompiler from the prebuild
|
||||
# would otherwise cause by holding file handles to those DLLs. Do not remove the prebuild
|
||||
# step: the subsequent `dotnet test --no-build` step and the publish's ProjectReference
|
||||
# resolution both depend on the prebuilt outputs being present.
|
||||
- name: Build and push Foundry Hosted Agents test container
|
||||
id: build-foundry-hosted-image
|
||||
shell: pwsh
|
||||
@@ -410,14 +393,13 @@ jobs:
|
||||
if ([string]::IsNullOrWhiteSpace($registry)) {
|
||||
throw "IT_HOSTED_AGENT_REGISTRY not set in the integration environment."
|
||||
}
|
||||
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry -UsePrebuiltProjectReferences | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||
& "${{ github.workspace }}/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1" -Registry $registry | Tee-Object -FilePath $env:GITHUB_ENV -Append
|
||||
|
||||
- name: Run Foundry Hosted Agents Integration Tests
|
||||
shell: pwsh
|
||||
working-directory: dotnet
|
||||
run: |
|
||||
dotnet test --solution ./filtered-foundry-hosted.slnx `
|
||||
-f $env:targetFramework `
|
||||
dotnet test --project tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj `
|
||||
-c $env:configuration `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
@@ -426,6 +408,12 @@ jobs:
|
||||
env:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
|
||||
# Azure AI Search (for the azure-search-rag scenario). Reuses the integration
|
||||
# environment secrets shared with python-sample-validation.yml. The index is
|
||||
# provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
|
||||
# for the required schema and seed content.
|
||||
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
|
||||
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
|
||||
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
|
||||
|
||||
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.53.0" />
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step22_MemorySearch/Agent_Step22_MemorySearch.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step23_LocalMCP/Agent_Step23_LocalMCP.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
|
||||
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Agent_Step25_FoundryToolboxMcp.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Evaluation/">
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
|
||||
@@ -313,6 +313,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files/HostedFiles.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj" />
|
||||
</Folder>
|
||||
@@ -325,6 +328,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/HostedAzureSearchRag.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj" />
|
||||
</Folder>
|
||||
@@ -332,6 +338,7 @@
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient/SessionFilesClient.csproj" />
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/">
|
||||
@@ -366,7 +373,7 @@
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
</Folder>
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
|
||||
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
|
||||
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.4.0</VersionPrefix>
|
||||
<VersionPrefix>1.5.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260505</DateSuffix>
|
||||
<DateSuffix>260507</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.4.0</GitTag>
|
||||
<GitTag>1.5.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+75
-2
@@ -8,6 +8,11 @@
|
||||
// even if the process is interrupted mid-loop, but may also result in chat history that is not
|
||||
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
|
||||
//
|
||||
// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool
|
||||
// code to inject new user messages during the function execution loop. When a tool or anything else enqueues
|
||||
// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient
|
||||
// detects the pending message before the next service call and includes the injected message in the request.
|
||||
//
|
||||
// To use end-of-run persistence instead (atomic run semantics), remove the
|
||||
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
|
||||
// persistence is the default behavior.
|
||||
@@ -54,6 +59,37 @@ static string GetTime([Description("The city name.")] string city) =>
|
||||
_ => $"{city}: time data not available."
|
||||
};
|
||||
|
||||
// This tool demonstrates message injection during the function execution loop.
|
||||
// When called, it checks travel advisories for a city. If an advisory is active, it uses
|
||||
// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message
|
||||
// asking for alternative destinations. The model will process this injected message on the next
|
||||
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
|
||||
[Description("Check current travel advisories for a city.")]
|
||||
static string CheckTravelAdvisory([Description("The city name.")] string city)
|
||||
{
|
||||
// Simulated travel advisory data.
|
||||
var advisory = city.ToUpperInvariant() switch
|
||||
{
|
||||
"LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.",
|
||||
"SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (advisory is null)
|
||||
{
|
||||
return $"{city}: No active travel advisories.";
|
||||
}
|
||||
|
||||
// When an advisory is found, inject a follow-up question so the model automatically
|
||||
// suggests alternatives without the user needing to ask.
|
||||
var runContext = AIAgent.CurrentRunContext!;
|
||||
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
|
||||
runContext.Session!,
|
||||
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
|
||||
|
||||
return advisory;
|
||||
}
|
||||
|
||||
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
|
||||
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
|
||||
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
|
||||
@@ -65,10 +101,11 @@ AIAgent agent = chatClient.AsAIAgent(
|
||||
{
|
||||
Name = "WeatherAssistant",
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
|
||||
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
|
||||
Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.",
|
||||
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)]
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,6 +146,18 @@ async Task RunNonStreamingAsync()
|
||||
response = await agent.RunAsync(FollowUp2, session);
|
||||
PrintAgentResponse(response.Text);
|
||||
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
|
||||
|
||||
// Fourth turn — demonstrates message injection during the function loop.
|
||||
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
|
||||
// user message asking for alternative cities. After the tool completes, the internal loop
|
||||
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
|
||||
// and calls the service again, so the model answers the follow-up automatically.
|
||||
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
|
||||
PrintUserMessage(TravelPrompt);
|
||||
|
||||
response = await agent.RunAsync(TravelPrompt, session);
|
||||
PrintAgentResponse(response.Text);
|
||||
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
|
||||
}
|
||||
|
||||
async Task RunStreamingAsync()
|
||||
@@ -181,6 +230,30 @@ async Task RunStreamingAsync()
|
||||
|
||||
Console.WriteLine();
|
||||
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
|
||||
|
||||
// Fourth turn — demonstrates message injection during the function loop (streaming).
|
||||
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
|
||||
// user message asking for alternative cities. After the tool completes, the internal loop
|
||||
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
|
||||
// and calls the service again, so the model answers the follow-up automatically.
|
||||
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
|
||||
PrintUserMessage(TravelPrompt);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[Agent] ");
|
||||
Console.ResetColor();
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session))
|
||||
{
|
||||
Console.Write(update);
|
||||
|
||||
// During streaming we should be able to see updates to the chat history
|
||||
// before the full run completes, as each service call is made and persisted.
|
||||
PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
|
||||
}
|
||||
|
||||
void PrintUserMessage(string message)
|
||||
|
||||
+7
-3
@@ -6,12 +6,16 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+70
-71
@@ -1,93 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to load a Foundry toolbox and pass its tools as server-side
|
||||
// tools when creating an agent. The Foundry platform handles tool execution — the agent
|
||||
// process does not invoke tools locally.
|
||||
// Foundry Toolbox via MCP (Streamable HTTP).
|
||||
//
|
||||
// Point an `McpClient` at a Foundry Toolbox's MCP endpoint. The agent
|
||||
// discovers the toolbox's tools at runtime and invokes them locally.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
#pragma warning disable CS8321 // Local functions may be commented-out alternatives
|
||||
|
||||
// Replace with your own Foundry toolbox name.
|
||||
// Must match the `<name>` segment of FOUNDRY_TOOLBOX_ENDPOINT.
|
||||
const string ToolboxName = "research_toolbox";
|
||||
// Used only by CombineToolboxes — swap in a second toolbox you own.
|
||||
const string SecondToolboxName = "analysis_toolbox";
|
||||
// Replace with any question that exercises the tools configured in your toolbox.
|
||||
const string Query = "Introduce yourself and briefly describe the tools you can use to help me.";
|
||||
const string Query = "What tools do you have access to?";
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint.");
|
||||
string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
string toolboxEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"FOUNDRY_TOOLBOX_ENDPOINT is not set. Example: " +
|
||||
"https://<account>.services.ai.azure.com/api/projects/<project>/toolsets/<name>/mcp?api-version=2025-05-01-preview");
|
||||
|
||||
TokenCredential credential = new DefaultAzureCredential();
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint, credential);
|
||||
|
||||
// Inject a fresh Azure AI bearer token on every MCP request.
|
||||
using var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
Console.WriteLine($"Connecting to toolbox MCP endpoint: {toolboxEndpoint}");
|
||||
|
||||
await using McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxEndpoint),
|
||||
Name = "foundry_toolbox",
|
||||
},
|
||||
httpClient));
|
||||
|
||||
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"Toolbox MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential());
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
await Main(projectClient, model, endpoint);
|
||||
// await CombineToolboxes(projectClient, model, endpoint);
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: "You are a helpful assistant. Use the available toolbox tools to answer the user.",
|
||||
name: "ToolboxMcpAgent",
|
||||
tools: [.. mcpTools.Cast<AITool>()]);
|
||||
|
||||
Console.WriteLine($"\nUser: {Query}\n");
|
||||
Console.WriteLine($"Assistant: {await agent.RunAsync(Query)}");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main: single toolbox
|
||||
// Helper: create (or replace) a sample toolbox so the sample runs end-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task Main(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ===");
|
||||
|
||||
// Comment out if the toolbox already exists in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
|
||||
// Omit the version to resolve the toolbox's current default version at runtime.
|
||||
var tools = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use the available tools to answer questions.",
|
||||
tools: tools.ToList());
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alternative: combine tools from multiple toolboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint)
|
||||
{
|
||||
Console.WriteLine("=== Combine Toolboxes Example ===");
|
||||
|
||||
// Comment out if the toolboxes already exist in your Foundry project.
|
||||
await CreateSampleToolboxAsync(ToolboxName, endpoint);
|
||||
await CreateSampleToolboxAsync(SecondToolboxName, endpoint);
|
||||
|
||||
var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName);
|
||||
var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName);
|
||||
|
||||
var allTools = toolboxA.Concat(toolboxB).ToList();
|
||||
|
||||
AIAgent agent = projectClient
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: "You are a research assistant. Use all available tools to answer questions.",
|
||||
tools: allTools);
|
||||
|
||||
Console.WriteLine($"User: {Query}");
|
||||
Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box
|
||||
// ---------------------------------------------------------------------------
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
static async Task CreateSampleToolboxAsync(string name, string endpoint, TokenCredential credential)
|
||||
{
|
||||
// Toolboxes are normally configured in the Foundry portal or a deployment
|
||||
// script, not the application itself. This helper exists so the sample can
|
||||
@@ -96,10 +83,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
// The Foundry-Features header is currently required for toolbox CRUD operations.
|
||||
var options = new AgentAdministrationClientOptions();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential(),
|
||||
options);
|
||||
var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, options);
|
||||
var toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
@@ -128,7 +112,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline policy that adds the Foundry-Features header for toolbox CRUD
|
||||
// Pipeline policy: adds the Foundry-Features header for toolbox CRUD calls
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
@@ -146,3 +130,18 @@ internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DelegatingHandler: attaches a fresh Azure AI bearer token to every request
|
||||
// ---------------------------------------------------------------------------
|
||||
internal sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Foundry Toolbox via MCP
|
||||
|
||||
This sample shows how to use a Foundry Toolbox by pointing an `McpClient` at the toolbox's MCP endpoint. The agent discovers the toolbox's tools at runtime and invokes them locally over MCP.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Connecting to a Foundry toolbox's MCP endpoint via Streamable HTTP transport
|
||||
- Injecting a fresh Azure AI bearer token (`https://ai.azure.com/.default`) on every MCP request
|
||||
- Passing the discovered MCP tools to `AIProjectClient.AsAIAgent(...)`
|
||||
- Optional helper to create (or replace) a sample toolbox in the project so the sample is runnable end-to-end
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a toolbox configured (or let the sample create one for you)
|
||||
- Azure CLI installed and authenticated (`az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
$env:FOUNDRY_TOOLBOX_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project/toolsets/research_toolbox/mcp?api-version=2025-05-01-preview"
|
||||
```
|
||||
|
||||
The `<name>` segment of `FOUNDRY_TOOLBOX_ENDPOINT` must match the `ToolboxName` constant in `Program.cs`.
|
||||
|
||||
## Run the sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
# Agent_Step25_ToolboxServerSideTools
|
||||
|
||||
This sample demonstrates loading a named Foundry toolbox and passing its tools as
|
||||
**server-side tools** when creating an agent via `AsAIAgent()`.
|
||||
|
||||
When tools from a toolbox are passed this way, they are sent as tool definitions in
|
||||
the Responses API request. The Foundry platform handles tool execution — the agent
|
||||
process does not invoke tools locally.
|
||||
|
||||
This is the dotnet equivalent of the Python sample:
|
||||
`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`)
|
||||
|
||||
The sample recreates the toolbox on each run, replacing any existing toolbox with
|
||||
the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep
|
||||
an existing toolbox unchanged.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the
|
||||
Foundry project API (resolving the default version if none is specified)
|
||||
2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance
|
||||
3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses
|
||||
API request as server-side tool definitions
|
||||
|
||||
For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call.
|
||||
|
||||
## Sample flows
|
||||
|
||||
| Flow | Description |
|
||||
|------|-------------|
|
||||
| `Main` (default) | Loads a single toolbox and runs an agent with its tools |
|
||||
| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent |
|
||||
|
||||
Uncomment the desired flow in the top-level statements to try each one.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
@@ -73,6 +73,7 @@ Some samples require extra tool-specific environment variables. See each sample
|
||||
| [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool |
|
||||
| [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport |
|
||||
| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter |
|
||||
| [Foundry toolbox via MCP](./Agent_Step25_FoundryToolboxMcp/) | Use a Foundry Toolbox from a non-hosted agent via its MCP endpoint |
|
||||
|
||||
## Running the samples
|
||||
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_SEARCH_ENDPOINT=<your-azure-search-endpoint>
|
||||
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
|
||||
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
|
||||
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-azure-search-rag \
|
||||
# -e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
|
||||
# -e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
|
||||
# --env-file .env hosted-azure-search-rag
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedAzureSearchRag.dll"]
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedAzureSearchRag</RootNamespace>
|
||||
<AssemblyName>HostedAzureSearchRag</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<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" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReferences above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to add Retrieval Augmented Generation (RAG) capabilities to a hosted
|
||||
// agent using Azure AI Search. The sample assumes the search index has already been provisioned
|
||||
// and populated out of band (see README.md for the required schema and example seed content).
|
||||
// A SearchClient-backed adapter is plugged into TextSearchProvider, which runs a keyword search
|
||||
// against the index before each model invocation and injects the matching documents into the
|
||||
// model context.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Azure.Search.Documents;
|
||||
using Azure.Search.Documents.Models;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string projectEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
string searchEndpoint = Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set.");
|
||||
string searchIndexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
|
||||
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set.");
|
||||
|
||||
// Use a chained credential. Try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in
|
||||
// production). The dev credential is scope aware so a single instance serves both Foundry and
|
||||
// Azure AI Search clients (each Azure SDK client requests a token for its own audience).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Connect to the pre-provisioned search index. The caller is expected to have created the
|
||||
// index and populated it with documents matching the schema (id / content / sourceName /
|
||||
// sourceLink) before running this sample. See README.md for an example provisioning script.
|
||||
var searchClient = new SearchClient(new Uri(searchEndpoint), searchIndexName, credential);
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 6,
|
||||
};
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-azure-search-rag",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
|
||||
"Answer questions using the provided context and cite the source document when available.",
|
||||
},
|
||||
AIContextProviders = [new TextSearchProvider(CreateSearchAdapter(searchClient), textSearchOptions)]
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Search adapter ───────────────────────────────────────────────────────────
|
||||
// Wraps a SearchClient as the delegate TextSearchProvider expects. Keyword/full-text only;
|
||||
// no embeddings. Returns the top results and projects them into TextSearchResult entries
|
||||
// the provider will inject into the model context.
|
||||
|
||||
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
|
||||
CreateSearchAdapter(SearchClient client, int top = 3) =>
|
||||
async (query, cancellationToken) =>
|
||||
{
|
||||
var options = new SearchOptions { Size = top };
|
||||
Response<SearchResults<SearchDocument>> response =
|
||||
await client.SearchAsync<SearchDocument>(query, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<TextSearchProvider.TextSearchResult>();
|
||||
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new TextSearchProvider.TextSearchResult
|
||||
{
|
||||
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
|
||||
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
|
||||
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
|
||||
RawRepresentation = hit
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A scope aware <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads pre-fetched bearer tokens from environment variables, dispensing the right token
|
||||
/// based on the requested scope:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ai.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_FOUNDRY</c></description></item>
|
||||
/// <item><description><c>search.azure.com</c> scopes -> <c>AZURE_BEARER_TOKEN_SEARCH</c></description></item>
|
||||
/// </list>
|
||||
/// For any other scope, throws <see cref="CredentialUnavailableException"/> so a chained
|
||||
/// credential will fall through. This should NOT be used in production: tokens expire (~1 hour)
|
||||
/// and cannot be refreshed.
|
||||
///
|
||||
/// Generate the tokens on your host and pass them to the container:
|
||||
/// <code>
|
||||
/// export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN_FOUNDRY -e AZURE_BEARER_TOKEN_SEARCH ...
|
||||
/// </code>
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string FoundryEnvironmentVariable = "AZURE_BEARER_TOKEN_FOUNDRY";
|
||||
private const string SearchEnvironmentVariable = "AZURE_BEARER_TOKEN_SEARCH";
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> Resolve(requestContext.Scopes);
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(Resolve(requestContext.Scopes));
|
||||
|
||||
private static AccessToken Resolve(IReadOnlyList<string> scopes)
|
||||
{
|
||||
string? envVar = null;
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
if (scope.Contains("search.azure.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
envVar = SearchEnvironmentVariable;
|
||||
break;
|
||||
}
|
||||
|
||||
if (scope.Contains("ai.azure.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
envVar = FoundryEnvironmentVariable;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (envVar is null)
|
||||
{
|
||||
throw new CredentialUnavailableException(
|
||||
$"DevTemporaryTokenCredential cannot serve scopes [{string.Join(", ", scopes)}]; falling through.");
|
||||
}
|
||||
|
||||
var token = Environment.GetEnvironmentVariable(envVar);
|
||||
if (string.IsNullOrEmpty(token) || string.Equals(token, "DefaultAzureCredential", StringComparison.Ordinal))
|
||||
{
|
||||
throw new CredentialUnavailableException(
|
||||
$"{envVar} environment variable is not set; falling through to next credential.");
|
||||
}
|
||||
|
||||
return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
# Hosted-AzureSearchRag
|
||||
|
||||
A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities backed by **Azure AI Search**. The agent grounds its answers in product documentation by running a keyword search against an Azure AI Search index before each model invocation, then citing the source in its response.
|
||||
|
||||
This sample is the Azure AI Search counterpart to `Hosted-TextRag`. Where `Hosted-TextRag` uses a mock in-process search function, this sample talks to a real Azure AI Search index that is provisioned out of band (see "Provisioning the search index" below).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- An Azure AI Search service ([create one](https://learn.microsoft.com/azure/search/search-create-service-portal))
|
||||
- **A pre-provisioned search index** with the schema and content described in the next section
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
### Required RBAC
|
||||
|
||||
Your identity (or the Managed Identity running the container in production) needs:
|
||||
|
||||
- **Azure AI User** on the Foundry project scope
|
||||
- **Search Index Data Reader** on the Azure AI Search service (the sample only reads from the index)
|
||||
|
||||
## Provisioning the search index (one time)
|
||||
|
||||
The sample assumes the search index already exists and contains documents the agent can retrieve from. Provision it once via the Azure Portal, the [REST API](https://learn.microsoft.com/azure/search/search-how-to-create-search-index), or the snippet below.
|
||||
|
||||
### Index schema
|
||||
|
||||
| Field | Type | Attributes |
|
||||
|---|---|---|
|
||||
| `id` | `Edm.String` | key, filterable |
|
||||
| `content` | `Edm.String` | searchable (full-text) |
|
||||
| `sourceName` | `Edm.String` | retrievable, filterable |
|
||||
| `sourceLink` | `Edm.String` | retrievable |
|
||||
|
||||
### Example: provision and seed via Azure CLI + REST
|
||||
|
||||
```bash
|
||||
SEARCH_ENDPOINT="https://<your-search>.search.windows.net"
|
||||
INDEX_NAME="contoso-outdoors"
|
||||
TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
|
||||
|
||||
# 1. Create the index.
|
||||
curl -X PUT "$SEARCH_ENDPOINT/indexes/$INDEX_NAME?api-version=2024-07-01" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "contoso-outdoors",
|
||||
"fields": [
|
||||
{ "name": "id", "type": "Edm.String", "key": true, "filterable": true, "searchable": false, "retrievable": true },
|
||||
{ "name": "content", "type": "Edm.String", "key": false, "filterable": false, "searchable": true, "retrievable": true, "analyzer": "standard.lucene" },
|
||||
{ "name": "sourceName", "type": "Edm.String", "key": false, "filterable": true, "searchable": false, "retrievable": true },
|
||||
{ "name": "sourceLink", "type": "Edm.String", "key": false, "filterable": false, "searchable": false, "retrievable": true }
|
||||
]
|
||||
}'
|
||||
|
||||
# 2. Upload three Contoso Outdoors documents matching the queries below.
|
||||
curl -X POST "$SEARCH_ENDPOINT/indexes/$INDEX_NAME/docs/index?api-version=2024-07-01" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"value": [
|
||||
{ "@search.action": "mergeOrUpload", "id": "return-policy", "sourceName": "Contoso Outdoors Return Policy", "sourceLink": "https://contoso.com/policies/returns", "content": "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection. As a thank you, every accepted return ships back with a complimentary Contoso TrailRunner sticker pack, item code TR-CANARY-7821." },
|
||||
{ "@search.action": "mergeOrUpload", "id": "shipping-guide", "sourceName": "Contoso Outdoors Shipping Guide", "sourceLink": "https://contoso.com/help/shipping", "content": "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout. Use promo code SHIP-CANARY-4493 at checkout for a one-time free overnight upgrade on your first order." },
|
||||
{ "@search.action": "mergeOrUpload", "id": "tent-care", "sourceName": "TrailRunner Tent Care Instructions", "sourceLink": "https://contoso.com/manuals/trailrunner-tent", "content": "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating. Replacement waterproofing kits are stocked under SKU TENT-CANARY-9067." }
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
You can also point the sample at any existing index that exposes the four fields above; the sample reads `content`, `sourceName`, and `sourceLink` as projected by the search results.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your endpoints:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_SEARCH_ENDPOINT=https://<your-search>.search.windows.net
|
||||
AZURE_SEARCH_INDEX_NAME=contoso-outdoors
|
||||
AZURE_BEARER_TOKEN_FOUNDRY=DefaultAzureCredential
|
||||
AZURE_BEARER_TOKEN_SEARCH=DefaultAzureCredential
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag
|
||||
AGENT_NAME=hosted-azure-search-rag dotnet run
|
||||
```
|
||||
|
||||
The agent will start on `http://localhost:8088`. The sample assumes the search index has already been provisioned and seeded (see "Provisioning the search index" above).
|
||||
|
||||
### Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What is your return policy?"
|
||||
azd ai agent invoke --local "How long does shipping take?"
|
||||
azd ai agent invoke --local "How do I clean my tent?"
|
||||
```
|
||||
|
||||
Or with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "What is your return policy?", "model": "hosted-azure-search-rag"}'
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-azure-search-rag .
|
||||
```
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate two bearer tokens on your host (one per audience) and pass them to the container. A single Azure AD token has only one `aud` claim, so Foundry and Azure AI Search require separate tokens.
|
||||
|
||||
```bash
|
||||
# Generate tokens (each expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN_FOUNDRY=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
export AZURE_BEARER_TOKEN_SEARCH=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with both tokens
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-azure-search-rag \
|
||||
-e AZURE_BEARER_TOKEN_FOUNDRY=$AZURE_BEARER_TOKEN_FOUNDRY \
|
||||
-e AZURE_BEARER_TOKEN_SEARCH=$AZURE_BEARER_TOKEN_SEARCH \
|
||||
--env-file .env \
|
||||
hosted-azure-search-rag
|
||||
```
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "What is your return policy?"
|
||||
```
|
||||
|
||||
## How RAG works in this sample
|
||||
|
||||
The `TextSearchProvider` runs a keyword search against the configured Azure AI Search index **before each model invocation**. When the index is seeded with the three Contoso Outdoors documents from the provisioning section above:
|
||||
|
||||
| User query mentions | Search result injected |
|
||||
|---|---|
|
||||
| "return", "refund" | Contoso Outdoors Return Policy (canary token: `TR-CANARY-7821`) |
|
||||
| "shipping", "promo" | Contoso Outdoors Shipping Guide (canary token: `SHIP-CANARY-4493`) |
|
||||
| "tent", "fabric" | TrailRunner Tent Care Instructions (canary token: `TENT-CANARY-9067`) |
|
||||
|
||||
The model receives the top three search results as additional context and cites the source in its response. Each seeded document includes a unique `*-CANARY-*` token that does not exist in any model training data, so the integration tests can prove an answer was grounded in retrieved content (not fabricated from training) by asking for the canary and asserting it appears in the response.
|
||||
|
||||
Replace the seed documents (or point the sample at an existing index with your own content) to ground the agent in your own knowledge base.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-azure-search-rag
|
||||
displayName: "Hosted Azure AI Search RAG Agent"
|
||||
|
||||
description: >
|
||||
A support specialist agent for Contoso Outdoors with RAG capabilities backed by
|
||||
Azure AI Search. Uses TextSearchProvider with a SearchClient adapter to ground
|
||||
answers in product documentation indexed in Azure AI Search before each model
|
||||
invocation.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- RAG
|
||||
- Azure AI Search
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-azure-search-rag
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-azure-search-rag
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -0,0 +1,6 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/.vs
|
||||
**/.vscode
|
||||
.env
|
||||
*.user
|
||||
@@ -0,0 +1,5 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
@@ -0,0 +1,17 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedFiles.dll"]
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-files .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-files -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-files
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedFiles.dll"]
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedFiles</RootNamespace>
|
||||
<AssemblyName>HostedFiles</AssemblyName>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Bake demo resources into the published output so the deployed agent's
|
||||
tools can read them from /app/resources/ inside the container. -->
|
||||
<Content Include="resources\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<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>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Hosted Files Agent - A hosted agent that exposes two distinct file knowledge sources
|
||||
// through scoped, security-hardened tools:
|
||||
//
|
||||
// * Bundled files (image-baked) — files copied into the published output via the csproj
|
||||
// <Content Include="resources\**"> rule. Live at /app/resources/ inside the container.
|
||||
// Author-shipped knowledge that ships with every session.
|
||||
//
|
||||
// * Session files (per-session $HOME volume) — files uploaded at runtime via the alpha
|
||||
// Azure.AI.Projects.AgentSessionFiles SDK. Live at $HOME inside the per-session
|
||||
// container, which the platform sets to /home/session by default
|
||||
// (container-image-spec.md line 127, "If you use the session files API, $HOME is
|
||||
// also the base path for those operations").
|
||||
//
|
||||
// Each source is exposed via a separate tool pair, each rooted at its own directory.
|
||||
// Tools take a fileName, not a path: Path.GetFileName strips any directory components,
|
||||
// then a canonicalize + StartsWith(root) check enforces the boundary. The model cannot
|
||||
// be tricked into reading /etc/passwd or any path outside its tool's root, even via
|
||||
// indirect prompt injection in an uploaded file.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
//
|
||||
// Optional:
|
||||
// AGENT_NAME - Agent name (default: hosted-files)
|
||||
// BUNDLED_FILES_DIR - Override the bundled-files root
|
||||
// (default: <baseDir>/resources, i.e. /app/resources/)
|
||||
// HOME - Standard env var; the per-session sandbox volume
|
||||
// (default: /home/session in the platform-managed container)
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// Bypass SampleEnvironment alias (which prompts on missing env vars) for optional values.
|
||||
string? GetOptionalEnv(string key) => System.Environment.GetEnvironmentVariable(key);
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = GetOptionalEnv("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// ── File roots (canonicalized once) ──────────────────────────────────────────
|
||||
|
||||
// Bundled root: where csproj <Content Include="resources\**"> lands at runtime.
|
||||
// In the container that resolves to /app/resources/.
|
||||
string bundledRoot = Path.GetFullPath(
|
||||
GetOptionalEnv("BUNDLED_FILES_DIR")
|
||||
?? Path.Combine(AppContext.BaseDirectory, "resources"));
|
||||
|
||||
// Session root: the per-session $HOME volume mounted by the Foundry platform.
|
||||
// Files uploaded via AgentSessionFiles.UploadSessionFileAsync(sessionStoragePath: "foo")
|
||||
// land at $HOME/foo per container-image-spec.md line 172.
|
||||
string sessionRoot = Path.GetFullPath(
|
||||
GetOptionalEnv("HOME")
|
||||
?? "/home/session");
|
||||
|
||||
// ── Tools: bundled files (image-baked, /app/resources/) ──────────────────────
|
||||
|
||||
[Description("List the names of files bundled with the agent (built-in knowledge that ships with the image).")]
|
||||
string ListBundledFiles() => SafeListNames(bundledRoot);
|
||||
|
||||
[Description("Read the full text contents of a bundled file by name. Bundled files are built-in knowledge shipped with the agent image.")]
|
||||
string ReadBundledFile(
|
||||
[Description("Name of the bundled file (no directory components). Must be one of the names returned by ListBundledFiles.")] string fileName)
|
||||
=> SafeRead(bundledRoot, fileName, scope: "bundled files");
|
||||
|
||||
// ── Tools: session files (per-session $HOME) ─────────────────────────────────
|
||||
|
||||
[Description("List the names of files uploaded into the current session sandbox by the user (e.g., via AgentSessionFiles.UploadSessionFileAsync).")]
|
||||
string ListSessionFiles() => SafeListNames(sessionRoot);
|
||||
|
||||
[Description("Read the full text contents of a file uploaded into the current session by name. Session files are user-supplied data that lives only for the lifetime of this session.")]
|
||||
string ReadSessionFile(
|
||||
[Description("Name of the session file (no directory components). Must be one of the names returned by ListSessionFiles.")] string fileName)
|
||||
=> SafeRead(sessionRoot, fileName, scope: "session files");
|
||||
|
||||
// ── Path-safe helpers (defense-in-depth: GetFileName + canonicalize + StartsWith(root)) ──
|
||||
|
||||
string SafeListNames(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
Directory.EnumerateFiles(root).Select(Path.GetFileName));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error listing files: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
string SafeRead(string root, string fileName, string scope)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Step 1: strip any directory components the model might have included.
|
||||
string safeName = Path.GetFileName(fileName);
|
||||
if (string.IsNullOrEmpty(safeName))
|
||||
{
|
||||
return $"File '{fileName}' not found in {scope}.";
|
||||
}
|
||||
|
||||
// Step 2: combine with the root and canonicalize.
|
||||
string fullPath = Path.GetFullPath(Path.Combine(root, safeName));
|
||||
|
||||
// Step 3: enforce the prefix boundary so a crafted name still cannot escape.
|
||||
string rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
|
||||
? root
|
||||
: root + Path.DirectorySeparatorChar;
|
||||
if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return $"File '{fileName}' not found in {scope}.";
|
||||
}
|
||||
|
||||
return File.Exists(fullPath)
|
||||
? File.ReadAllText(fullPath)
|
||||
: $"File '{fileName}' not found in {scope}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error reading '{fileName}': {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create and host the agent ────────────────────────────────────────────────
|
||||
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a friendly assistant that answers questions over two file sources:
|
||||
|
||||
- Bundled files: built-in knowledge that ships with the agent image
|
||||
(e.g., reference reports the author packaged with you). Tools:
|
||||
ListBundledFiles, ReadBundledFile.
|
||||
|
||||
- Session files: user-uploaded data for this session only (e.g., a CSV
|
||||
the user wants you to analyse). Tools: ListSessionFiles, ReadSessionFile.
|
||||
|
||||
Pick the tool pair by intent. If a name could match either source, list
|
||||
both first. Always read the file before answering; do not guess. Quote
|
||||
numbers and figures verbatim from the file.
|
||||
""",
|
||||
name: GetOptionalEnv("AGENT_NAME") ?? "hosted-files",
|
||||
description: "Hosted agent that answers questions over bundled (image-baked) and session-uploaded files via two scoped tool pairs.",
|
||||
tools:
|
||||
[
|
||||
AIFunctionFactory.Create(ListBundledFiles),
|
||||
AIFunctionFactory.Create(ReadBundledFile),
|
||||
AIFunctionFactory.Create(ListSessionFiles),
|
||||
AIFunctionFactory.Create(ReadSessionFile),
|
||||
]);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = System.Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# Hosted-Files
|
||||
|
||||
A hosted agent that demonstrates **two distinct file knowledge sources** through scoped, security-hardened tools:
|
||||
|
||||
- **Bundled files** (image-baked) — files the author packages with the agent at build time. Live at `/app/resources/` inside the container, copied from this project's [`resources/`](./resources/) folder via the csproj `<Content Include="resources\**\*" CopyToOutputDirectory="PreserveNewest" />` rule.
|
||||
- **Session files** (per-session `$HOME` volume) — files the user uploads at runtime via the alpha `Azure.AI.Projects.AgentSessionFiles` SDK. Live at `$HOME` inside the per-session container. The Foundry platform sets `HOME=/home/session` by default and roots the session-files API there per [`container-image-spec.md` line 172](https://github.com/microsoft/foundrysdk-specs/blob/main/specs/agents/hosted_agents/container-spec/docs/container-image-spec.md): *"If you use the session files API, `$HOME` is also the base path for those operations; any paths given in those API endpoints will be relative to `$HOME`."*
|
||||
|
||||
## Tool surface
|
||||
|
||||
Each source is exposed via its own tool pair, rooted at its own directory. The model picks by intent.
|
||||
|
||||
| Tool | Source | Root |
|
||||
|------|--------|------|
|
||||
| `ListBundledFiles` | Bundled (image-baked) | `/app/resources/` |
|
||||
| `ReadBundledFile` | Bundled (image-baked) | `/app/resources/` |
|
||||
| `ListSessionFiles` | Session-uploaded | `$HOME` (`/home/session`) |
|
||||
| `ReadSessionFile` | Session-uploaded | `$HOME` (`/home/session`) |
|
||||
|
||||
## Security model — distinct tools, distinct sandboxes
|
||||
|
||||
Each tool takes a `fileName` (no directory components allowed) and enforces three layers of defence inside the implementation:
|
||||
|
||||
1. **`Path.GetFileName(input)`** strips any directory parts from the model-supplied name. `"../../etc/passwd"` becomes `"passwd"`.
|
||||
2. **`Path.GetFullPath(Combine(root, name))`** canonicalises the path.
|
||||
3. **`fullPath.StartsWith(root + DirectorySeparatorChar)`** rejects anything that resolves outside the tool's root.
|
||||
|
||||
Failures return a controlled `"File '<input>' not found in <scope>."` rather than throwing or exposing the canonical path.
|
||||
|
||||
This is why the agent has four narrowly-scoped tools instead of a single `ReadFile(path)`:
|
||||
|
||||
- **Smaller per-tool attack surface.** Each tool has one purpose, one root, and no path-typed parameter. Even a buggy implementation can only leak its own directory.
|
||||
- **Cross-boundary access is impossible by schema.** A prompt-injection attempt to make the bundled tool read a session path (or vice versa) does not even compile in the tool schema the model sees.
|
||||
- **Read-only, non-recursive listing.** No write tools, no glob, no `..`.
|
||||
|
||||
## Companion
|
||||
|
||||
[`Using-Samples/SessionFilesClient`](../Using-Samples/SessionFilesClient/) — a thin chat REPL (same shape as [`SimpleAgent`](../Using-Samples/SimpleAgent/)) that points at the deployed Hosted-Files endpoint via `FoundryAgent` and lets you ask questions whose answers come from either file source.
|
||||
|
||||
## Live proof of the session-files contract
|
||||
|
||||
The end-to-end alpha-SDK round trip (client uploads via `AgentSessionFiles.UploadSessionFileAsync` → file arrives at `$HOME/<name>` inside the per-session container → agent's `ReadSessionFile` tool reads it → response quotes the verbatim contents) is exercised live by [`SessionFilesHostedAgentTests.UploadedFile_IsReadByHostedAgentAsync`](../../../../../tests/Foundry.Hosting.IntegrationTests/SessionFilesHostedAgentTests.cs) against the matching `session-files` scenario in the integration test container.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
```
|
||||
|
||||
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files
|
||||
AGENT_NAME=hosted-files dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
|
||||
## Try it from the SessionFilesClient REPL
|
||||
|
||||
### Bundled files (works against any deployment, including local)
|
||||
|
||||
```bash
|
||||
cd ../Using-Samples/SessionFilesClient
|
||||
$env:AGENT_ENDPOINT = "http://localhost:8088"
|
||||
$env:AGENT_NAME = "hosted-files"
|
||||
dotnet run
|
||||
|
||||
You> What is the total revenue in the contoso file?
|
||||
Agent> The contoso file reports total revenue of "$1,482.6M".
|
||||
```
|
||||
|
||||
The agent calls `ListBundledFiles`, sees `contoso_q1_2026_report.txt`, calls `ReadBundledFile("contoso_q1_2026_report.txt")` (which resolves under `/app/resources/`), and quotes the figure verbatim.
|
||||
|
||||
### Session files (against a deployed agent)
|
||||
|
||||
Upload a file to a specific session via `azd ai agent files upload` or via the alpha `AgentSessionFiles` SDK (see the integration test for the SDK call), then ask the agent about it. The agent's `ReadSessionFile` tool reads from `$HOME` and surfaces the content the same way.
|
||||
|
||||
## Running with Docker
|
||||
|
||||
This project uses `ProjectReference`, so use `Dockerfile.contributor` which takes a pre-published output:
|
||||
|
||||
```bash
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
docker build -f Dockerfile.contributor -t hosted-files .
|
||||
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-files \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-files
|
||||
```
|
||||
|
||||
The bundled `resources/` folder is part of the published output and ships inside the image.
|
||||
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor` and switch the `ProjectReference` entries in `HostedFiles.csproj` to `PackageReference` (commented section in the csproj).
|
||||
|
||||
## Adding more bundled files
|
||||
|
||||
Drop additional text files into [`resources/`](./resources/). The csproj `<Content Include="resources\**\*" CopyToOutputDirectory="PreserveNewest" />` rule picks them up on the next `dotnet build` / `docker build`.
|
||||
|
||||
## Overrides
|
||||
|
||||
| Env var | Purpose | Default |
|
||||
|---------|---------|---------|
|
||||
| `BUNDLED_FILES_DIR` | Override the bundled-files root the tools read from. | `<process base dir>/resources` (`/app/resources/` in container) |
|
||||
| `HOME` | The per-session sandbox volume root the session-files tools read from. Set by the Foundry platform; can be overridden for local testing. | `/home/session` |
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-files
|
||||
displayName: "Hosted Files Agent"
|
||||
|
||||
description: >
|
||||
A hosted agent that answers questions over a small set of files bundled
|
||||
with its container image (under /app/resources/). Two local C# function
|
||||
tools (ListFiles, ReadFile) surface the bundled file contents to the model.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Bundled Files
|
||||
- Local Tools
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-files
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-files
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
Contoso Corporation
|
||||
Quarterly Report — Q1 2026 (Three months ended March 31, 2026)
|
||||
|
||||
DISCLAIMER
|
||||
This document contains fictional data for sample/demo purposes only.
|
||||
Contoso is a fictional company; all figures below are fabricated.
|
||||
|
||||
------------------------------------------------------------
|
||||
1. EXECUTIVE SUMMARY
|
||||
------------------------------------------------------------
|
||||
Contoso delivered a solid first quarter, with total revenue of
|
||||
$1,482.6M, up 11.4% year-over-year. Growth was led by the Cloud
|
||||
Services segment (+22.7% YoY) and continued double-digit expansion
|
||||
in International markets. Operating margin expanded 140 basis points
|
||||
to 23.8% on disciplined cost management and improved gross margin.
|
||||
|
||||
Key highlights:
|
||||
- Revenue: $1,482.6M (YoY +11.4%)
|
||||
- Gross profit: $912.0M (gross margin 61.5%)
|
||||
- Operating income: $352.9M (operating margin 23.8%)
|
||||
- Net income: $268.4M (net margin 18.1%)
|
||||
- Diluted EPS: $1.27 (vs. $1.04 prior year)
|
||||
- Free cash flow: $311.5M
|
||||
- Cash & equivalents: $2,140.8M
|
||||
|
||||
------------------------------------------------------------
|
||||
2. INCOME STATEMENT (USD millions, unaudited)
|
||||
------------------------------------------------------------
|
||||
Q1 2026 Q1 2025 YoY %
|
||||
Revenue 1,482.6 1,330.7 +11.4%
|
||||
Cost of revenue 570.6 538.9 +5.9%
|
||||
Gross profit 912.0 791.8 +15.2%
|
||||
Gross margin 61.5% 59.5% +200 bps
|
||||
Operating expenses
|
||||
Research & development 241.4 220.5 +9.5%
|
||||
Sales & marketing 218.7 205.1 +6.6%
|
||||
General & administrative 99.0 88.6 +11.7%
|
||||
Total operating expenses 559.1 514.2 +8.7%
|
||||
Operating income 352.9 277.6 +27.1%
|
||||
Operating margin 23.8% 20.9% +290 bps
|
||||
Other income / (expense), net 8.4 5.1
|
||||
Income before taxes 361.3 282.7
|
||||
Provision for income taxes 92.9 72.6
|
||||
Net income 268.4 210.1 +27.7%
|
||||
Diluted EPS (USD) 1.27 1.04 +22.1%
|
||||
|
||||
------------------------------------------------------------
|
||||
3. REVENUE BY SEGMENT (USD millions)
|
||||
------------------------------------------------------------
|
||||
Segment Q1 2026 Q1 2025 YoY %
|
||||
Cloud Services 612.4 499.1 +22.7%
|
||||
Productivity Software 448.9 422.6 +6.2%
|
||||
Devices & Hardware 267.0 260.4 +2.5%
|
||||
Professional Services 154.3 148.6 +3.8%
|
||||
Total revenue 1,482.6 1,330.7 +11.4%
|
||||
|
||||
------------------------------------------------------------
|
||||
4. REVENUE BY GEOGRAPHY (USD millions)
|
||||
------------------------------------------------------------
|
||||
Region Q1 2026 Q1 2025 YoY %
|
||||
North America 812.1 756.0 +7.4%
|
||||
EMEA 388.5 340.2 +14.2%
|
||||
Asia-Pacific 221.7 183.4 +20.9%
|
||||
Latin America 60.3 51.1 +18.0%
|
||||
Total revenue 1,482.6 1,330.7 +11.4%
|
||||
|
||||
------------------------------------------------------------
|
||||
5. SELECTED BALANCE SHEET ITEMS (USD millions)
|
||||
------------------------------------------------------------
|
||||
Mar 31, Dec 31,
|
||||
2026 2025
|
||||
Cash & equivalents 2,140.8 1,902.3
|
||||
Short-term investments 845.6 820.4
|
||||
Accounts receivable, net 1,012.7 988.5
|
||||
Total current assets 4,510.2 4,190.6
|
||||
Goodwill & intangibles 2,330.1 2,338.9
|
||||
Total assets 9,884.5 9,512.0
|
||||
Total current liabilities 2,118.4 2,054.7
|
||||
Long-term debt 1,750.0 1,750.0
|
||||
Total liabilities 4,402.6 4,310.5
|
||||
Total stockholders' equity 5,481.9 5,201.5
|
||||
|
||||
------------------------------------------------------------
|
||||
6. CASH FLOW HIGHLIGHTS (USD millions)
|
||||
------------------------------------------------------------
|
||||
Q1 2026 Q1 2025
|
||||
Net cash from operating activities 382.0 298.7
|
||||
Capital expenditures (70.5) (62.1)
|
||||
Free cash flow 311.5 236.6
|
||||
Share repurchases (120.0) (90.0)
|
||||
Dividends paid (54.2) (48.6)
|
||||
|
||||
------------------------------------------------------------
|
||||
7. KEY OPERATING METRICS
|
||||
------------------------------------------------------------
|
||||
Cloud paid seats (millions) 48.6 39.7 +22.4%
|
||||
Cloud net revenue retention 118% 114%
|
||||
Active enterprise customers 18,420 16,905 +9.0%
|
||||
Headcount (end of period) 22,140 20,610 +7.4%
|
||||
|
||||
------------------------------------------------------------
|
||||
8. OUTLOOK — Q2 2026 GUIDANCE
|
||||
------------------------------------------------------------
|
||||
Revenue: $1,520M – $1,560M (YoY +10% to +13%)
|
||||
Operating margin: 23.5% – 24.5%
|
||||
Diluted EPS: $1.30 – $1.36
|
||||
Capital expenditures: ~$80M
|
||||
|
||||
Management remains confident in the full-year plan and reiterates
|
||||
fiscal-year 2026 revenue growth of 10–12% and operating-margin
|
||||
expansion of 100–150 basis points versus FY 2025.
|
||||
|
||||
------------------------------------------------------------
|
||||
9. NOTES
|
||||
------------------------------------------------------------
|
||||
- All figures are unaudited and rounded to one decimal place.
|
||||
- Year-over-year comparisons are versus the same period in 2025.
|
||||
- "Free cash flow" is defined as net cash from operating activities
|
||||
less capital expenditures, and is a non-GAAP measure.
|
||||
- This sample report is intended solely for demonstration of an
|
||||
agent-driven document analysis pipeline.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT")
|
||||
?? "http://localhost:8088");
|
||||
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
|
||||
// ── Create an agent-framework agent backed by the remote Hosted-Files agent ──
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (agentEndpoint.Scheme == "http")
|
||||
{
|
||||
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
|
||||
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
|
||||
// before the request hits the wire.
|
||||
|
||||
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
|
||||
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
|
||||
}
|
||||
|
||||
var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName));
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// ── REPL ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Session Files Client
|
||||
Connected to: {agentEndpoint}
|
||||
Try: "Give me the total revenue in the contoso file."
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
""");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("You> ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? input = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input)) { continue; }
|
||||
if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; }
|
||||
|
||||
try
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("Agent> ");
|
||||
Console.ResetColor();
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync(input, session))
|
||||
{
|
||||
Console.Write(update);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Goodbye!");
|
||||
|
||||
/// <summary>
|
||||
/// For Local Development Only
|
||||
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
|
||||
/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
|
||||
/// </summary>
|
||||
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
RewriteScheme(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void RewriteScheme(PipelineMessage message)
|
||||
{
|
||||
var uri = message.Request.Uri!;
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# SessionFilesClient
|
||||
|
||||
A thin chat REPL that connects to a deployed [`Hosted-Files`](../../Hosted-Files/) agent via `FoundryAgent` and lets you ask questions whose answers come from the files bundled with that agent. Same shape as [`SimpleAgent`](../SimpleAgent/) — point it at an `AGENT_ENDPOINT`, build a `FoundryAgent`, run.
|
||||
|
||||
The agent's container-side `ListFiles` and `ReadFile` tools surface the bundled file contents to the model. The client knows nothing about files; that is entirely the agent's concern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- A running [`Hosted-Files`](../../Hosted-Files/) agent (locally via `dotnet run` or deployed to Foundry)
|
||||
- Azure CLI logged in (`az login`)
|
||||
|
||||
## Configuration
|
||||
|
||||
```env
|
||||
AGENT_ENDPOINT=http://localhost:8088
|
||||
AGENT_NAME=hosted-files
|
||||
```
|
||||
|
||||
`AGENT_ENDPOINT` defaults to `http://localhost:8088`. Override with the deployed agent endpoint when chatting against Foundry.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SessionFilesClient
|
||||
$env:AGENT_ENDPOINT = "http://localhost:8088"
|
||||
$env:AGENT_NAME = "hosted-files"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## End-to-end demo
|
||||
|
||||
With the [`Hosted-Files`](../../Hosted-Files/) agent running:
|
||||
|
||||
```text
|
||||
══════════════════════════════════════════════════════════
|
||||
Session Files Client
|
||||
Connected to: http://localhost:8088/
|
||||
Try: "Give me the total revenue in the contoso file."
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
|
||||
You> Give me the total revenue in the contoso file.
|
||||
Agent> The contoso file reports total revenue of "$1,482.6M".
|
||||
|
||||
You> quit
|
||||
Goodbye!
|
||||
```
|
||||
|
||||
The agent looked at its bundled files via `ListFiles`, picked `contoso_q1_2026_report.txt`, called `ReadFile`, and quoted the figure verbatim. The client only sent a chat prompt.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>SessionFilesClient</RootNamespace>
|
||||
<AssemblyName>session-files-client</AssemblyName>
|
||||
<NoWarn>$(NoWarn);NU1605;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -105,7 +105,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
var allNewMessages = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
|
||||
state.Messages.AddRange(allNewMessages);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint filter that enforces the DevUI security posture: loopback-only
|
||||
/// access by default, plus optional bearer-token authentication.
|
||||
/// </summary>
|
||||
internal sealed class DevUIAuthFilter : IEndpointFilter
|
||||
{
|
||||
private const string BearerScheme = "Bearer";
|
||||
|
||||
private readonly DevUIOptions _options;
|
||||
private readonly byte[]? _expectedTokenBytes;
|
||||
private readonly ILogger<DevUIAuthFilter> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a bearer token is required by this filter
|
||||
/// (either via <see cref="DevUIOptions.AuthToken"/> or the
|
||||
/// <c>DEVUI_AUTH_TOKEN</c> environment variable).
|
||||
/// </summary>
|
||||
public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };
|
||||
|
||||
public DevUIAuthFilter(IOptions<DevUIOptions> options, ILogger<DevUIAuthFilter> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
this._options = options.Value;
|
||||
this._logger = logger;
|
||||
|
||||
var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken)
|
||||
? this._options.AuthToken
|
||||
: Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable);
|
||||
|
||||
this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
|
||||
? Encoding.UTF8.GetBytes(configuredToken)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
var remoteIp = httpContext.Connection.RemoteIpAddress;
|
||||
var isLoopback = remoteIp is not null && IPAddress.IsLoopback(remoteIp);
|
||||
|
||||
if (!isLoopback && !this._options.AllowRemoteAccess)
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.",
|
||||
remoteIp);
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
title: "DevUI access denied",
|
||||
detail: "DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access.");
|
||||
}
|
||||
|
||||
if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(httpContext.Request, expected))
|
||||
{
|
||||
httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme;
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status401Unauthorized,
|
||||
title: "DevUI authentication required",
|
||||
detail: "Provide a valid bearer token via the Authorization header.");
|
||||
}
|
||||
|
||||
return await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool TokenIsValid(HttpRequest request, byte[] expected)
|
||||
{
|
||||
if (!request.Headers.TryGetValue(HeaderNames.Authorization, out var headerValues))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var header in headerValues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const int PrefixLength = 7; // "Bearer "
|
||||
if (header.Length <= PrefixLength ||
|
||||
!header.StartsWith(BearerScheme, StringComparison.OrdinalIgnoreCase) ||
|
||||
header[BearerScheme.Length] != ' ')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var presented = Encoding.UTF8.GetBytes(header.AsSpan(PrefixLength).Trim().ToString());
|
||||
if (CryptographicOperations.FixedTimeEquals(presented, expected))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
@@ -13,12 +14,19 @@ public static class DevUIExtensions
|
||||
/// Maps an endpoint that serves the DevUI from the '/devui' path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
|
||||
/// and the corresponding endpoints to be mapped using
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
|
||||
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// DevUI is restricted to loopback callers unless
|
||||
/// <see cref="DevUIOptions.AllowRemoteAccess"/> is set. See <see cref="DevUIOptions"/>
|
||||
/// for the available authentication and authorization hooks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
|
||||
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
|
||||
@@ -30,11 +38,29 @@ public static class DevUIExtensions
|
||||
public static IEndpointConventionBuilder MapDevUI(
|
||||
this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("");
|
||||
group.MapDevUI(pattern: "/devui");
|
||||
group.MapMeta();
|
||||
group.MapEntities();
|
||||
return group;
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var authFilter = endpoints.ServiceProvider.GetRequiredService<DevUIAuthFilter>();
|
||||
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<DevUIOptions>>().Value;
|
||||
var startupLogger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIAuthFilter>>();
|
||||
|
||||
WarnIfInsecurelyExposed(startupLogger, options);
|
||||
|
||||
// /meta must remain reachable without authentication so the frontend can
|
||||
// discover whether a bearer token is required before prompting for one.
|
||||
endpoints.MapMeta(authRequired: authFilter.TokenRequired);
|
||||
|
||||
var protectedGroup = endpoints.MapGroup("");
|
||||
|
||||
// Conventions must be applied before endpoints are added to the group so
|
||||
// they reliably attach to every protected DevUI endpoint.
|
||||
options.ConfigureEndpoints?.Invoke(protectedGroup);
|
||||
protectedGroup.AddEndpointFilter(authFilter);
|
||||
|
||||
protectedGroup.MapDevUI(pattern: "/devui");
|
||||
protectedGroup.MapEntities();
|
||||
|
||||
return protectedGroup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,4 +92,18 @@ public static class DevUIExtensions
|
||||
.WithName($"DevUI at {cleanPattern}")
|
||||
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
|
||||
}
|
||||
|
||||
private static void WarnIfInsecurelyExposed(ILogger logger, DevUIOptions options)
|
||||
{
|
||||
var tokenConfigured = !string.IsNullOrEmpty(options.AuthToken)
|
||||
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable));
|
||||
|
||||
if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"DevUI is configured with AllowRemoteAccess=true and no authentication. " +
|
||||
"Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.",
|
||||
DevUIOptions.AuthTokenEnvironmentVariable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI;
|
||||
|
||||
/// <summary>
|
||||
/// Options that control the security posture of the DevUI HTTP surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DevUI exposes agent metadata that is sensitive in production contexts:
|
||||
/// system instructions, tool definitions, model identifiers, and workflow
|
||||
/// structure. By default, DevUI rejects any request whose remote endpoint
|
||||
/// is not a loopback address. Hosts that intentionally expose DevUI on a
|
||||
/// non-loopback interface must opt in via <see cref="AllowRemoteAccess"/>
|
||||
/// and should also configure <see cref="AuthToken"/> or
|
||||
/// <see cref="ConfigureEndpoints"/> to attach an authorization policy.
|
||||
/// </remarks>
|
||||
public sealed class DevUIOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variable inspected for a default bearer token when
|
||||
/// <see cref="AuthToken"/> is not explicitly set.
|
||||
/// </summary>
|
||||
public const string AuthTokenEnvironmentVariable = "DEVUI_AUTH_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether DevUI may be served to
|
||||
/// non-loopback callers. Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="false"/>, any request whose
|
||||
/// <see cref="ConnectionInfo.RemoteIpAddress"/> is
|
||||
/// not a loopback address (or is missing) is rejected with HTTP 403 before
|
||||
/// reaching the DevUI handlers. Enable only when the host is responsible
|
||||
/// for fronting DevUI with its own authentication, network policy, or both.
|
||||
/// </remarks>
|
||||
public bool AllowRemoteAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a shared bearer token required on every DevUI request.
|
||||
/// When <see langword="null"/> or empty, the value of the
|
||||
/// <c>DEVUI_AUTH_TOKEN</c> environment variable is used instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a token is configured, requests must include the header
|
||||
/// <c>Authorization: Bearer <token></c>. Comparison is performed
|
||||
/// in constant time. This is a convenience for development scenarios.
|
||||
/// Production hosts should prefer a real ASP.NET Core authentication
|
||||
/// scheme attached via <see cref="ConfigureEndpoints"/>.
|
||||
/// </remarks>
|
||||
public string? AuthToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a callback invoked with the DevUI endpoint group so the
|
||||
/// host can attach authorization, rate limiting, or other endpoint
|
||||
/// conventions (for example
|
||||
/// <c>group.RequireAuthorization("DevUIPolicy")</c>).
|
||||
/// </summary>
|
||||
public Action<IEndpointConventionBuilder>? ConfigureEndpoints { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
/// <summary>
|
||||
@@ -13,10 +15,19 @@ public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
|
||||
=> AddDevUI(builder, configure: null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds DevUI services to the host application builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
|
||||
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
|
||||
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
|
||||
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action<DevUIOptions>? configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(configure);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class MetaApiExtensions
|
||||
/// Maps the HTTP API endpoint for retrieving server metadata.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
|
||||
/// <param name="authRequired">Value reported via <c>auth_required</c> in the meta response so the frontend can decide whether to prompt for a bearer token.</param>
|
||||
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// This extension method registers the following endpoint:
|
||||
@@ -22,16 +23,16 @@ internal static class MetaApiExtensions
|
||||
/// The endpoint is compatible with the Python DevUI frontend and provides essential
|
||||
/// configuration information needed for proper frontend initialization.
|
||||
/// </remarks>
|
||||
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
|
||||
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints, bool authRequired = false)
|
||||
{
|
||||
return endpoints.MapGet("/meta", GetMeta)
|
||||
return endpoints.MapGet("/meta", () => GetMeta(authRequired))
|
||||
.WithName("GetMeta")
|
||||
.WithSummary("Get server metadata and configuration")
|
||||
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
|
||||
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
}
|
||||
|
||||
private static IResult GetMeta()
|
||||
private static IResult GetMeta(bool authRequired)
|
||||
{
|
||||
// TODO: Consider making these configurable via IOptions<DevUIOptions>
|
||||
// For now, using sensible defaults that match Python DevUI behavior
|
||||
@@ -53,7 +54,7 @@ internal static class MetaApiExtensions
|
||||
// Deployment capability - not currently supported in .NET DevUI
|
||||
["deployment"] = false
|
||||
},
|
||||
AuthRequired = false // Could be made configurable based on authentication middleware
|
||||
AuthRequired = authRequired
|
||||
};
|
||||
|
||||
return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
This package provides a web interface for testing and debugging AI agents during development.
|
||||
|
||||
> [!WARNING]
|
||||
> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI rejects any request whose remote endpoint is not a loopback address; see [Security](#security) below for the available options.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -48,3 +51,30 @@ if (builder.Environment.IsDevelopment())
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that:
|
||||
|
||||
- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default.
|
||||
- Optionally requires a shared bearer token on every request.
|
||||
|
||||
Configure via `DevUIOptions`:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI(options =>
|
||||
{
|
||||
// Allow non-loopback callers. Set this only when the host fronts DevUI with
|
||||
// its own authentication or network policy.
|
||||
options.AllowRemoteAccess = true;
|
||||
|
||||
// Optional: require Authorization: Bearer <token> on every request.
|
||||
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
|
||||
options.AuthToken = builder.Configuration["DevUI:AuthToken"];
|
||||
|
||||
// Optional: attach a real authorization policy or rate limiting.
|
||||
options.ConfigureEndpoints = group => group.RequireAuthorization("DevUIPolicy");
|
||||
});
|
||||
```
|
||||
|
||||
The bundled bearer-token check uses constant-time comparison and is intended as a convenience for development scenarios. Production hosts should prefer a real ASP.NET Core authentication scheme via `ConfigureEndpoints`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DevUI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -17,9 +18,26 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
|
||||
public static IServiceCollection AddDevUI(this IServiceCollection services)
|
||||
=> AddDevUI(services, configure: null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds services required for DevUI integration.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
|
||||
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
|
||||
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
|
||||
public static IServiceCollection AddDevUI(this IServiceCollection services, Action<DevUIOptions>? configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var optionsBuilder = services.AddOptions<DevUIOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<DevUIAuthFilter>();
|
||||
|
||||
// a factory that tries to construct an AIAgent from Workflow,
|
||||
// even if workflow was not explicitly registered as an AIAgent.
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
|
||||
namespace Azure.AI.Projects;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods on <see cref="AIProjectClient"/> for fetching
|
||||
/// Foundry toolbox definitions as server-side tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Provides a single call on the project client to retrieve tools ready for use
|
||||
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class AIProjectClientToolboxExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectClient">The <see cref="AIProjectClient"/> to use. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="System.ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectClient"/> or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolboxToolsAsync(
|
||||
this AIProjectClient projectClient,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectClient);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes();
|
||||
var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents
|
||||
#pragma warning disable IL2026 // ModelReaderWriter.Read<ResponseTool> uses reflection; suppressed for Azure SDK model types.
|
||||
#pragma warning disable IL3050 // ModelReaderWriter.Read<ResponseTool> requires dynamic code; suppressed for Azure SDK model types.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for fetching Foundry toolbox definitions and converting their tools
|
||||
/// to <see cref="AITool"/> instances for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When tools from a toolbox are passed to a Foundry agent (e.g. via <c>AsAIAgent(model, instructions, tools: ...)</c>),
|
||||
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
|
||||
/// handles tool execution — the agent process does not invoke tools locally.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryToolbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a toolbox version from the Foundry project and returns the raw SDK <see cref="ToolboxVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically (requires an additional API call).
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>The <see cref="ToolboxVersion"/> containing tool definitions.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a toolbox from the Foundry project and returns its tools as <see cref="AITool"/> instances
|
||||
/// ready for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="projectEndpoint">The Foundry project endpoint URI.</param>
|
||||
/// <param name="credential">The authentication credential used to access the Foundry project.</param>
|
||||
/// <param name="name">The name of the toolbox to fetch.</param>
|
||||
/// <param name="version">
|
||||
/// The specific toolbox version to fetch. When <see langword="null"/>, the toolbox's
|
||||
/// default version is resolved automatically.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances from the toolbox.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="projectEndpoint"/>, <paramref name="credential"/>, or <paramref name="name"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ClientResultException">Thrown when the Foundry project API returns an error.</exception>
|
||||
public static async Task<IReadOnlyList<AITool>> GetToolsAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false);
|
||||
return toolboxVersion.ToAITools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the tools in a <see cref="ToolboxVersion"/> to <see cref="AITool"/> instances
|
||||
/// suitable for use as server-side tools in the Responses API.
|
||||
/// </summary>
|
||||
/// <param name="toolboxVersion">The toolbox version whose tools to convert.</param>
|
||||
/// <returns>A read-only list of <see cref="AITool"/> instances.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="toolboxVersion"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each <see cref="ProjectsAgentTool"/> in the toolbox is cast to <see cref="ResponseTool"/>
|
||||
/// and converted via <c>AsAITool()</c>. Non-function hosted tools (MCP, web_search,
|
||||
/// code_interpreter, etc.) are included as server-side tool definitions — the Foundry
|
||||
/// platform handles their execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Non-function tools are sanitized to remove decoration fields (<c>name</c>, <c>description</c>)
|
||||
/// that the toolbox API returns but the Responses API rejects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<AITool> ToAITools(this ToolboxVersion toolboxVersion)
|
||||
{
|
||||
Throw.IfNull(toolboxVersion);
|
||||
|
||||
if (toolboxVersion.Tools?.Any() != true)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return toolboxVersion.Tools
|
||||
.Select(SanitizeAndConvert)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#region Internal helpers (visible to unit tests via InternalsVisibleTo)
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a <see cref="ProjectsAgentTool"/> by removing decoration fields that the
|
||||
/// toolbox API returns but the Responses API rejects, then converts to <see cref="AITool"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Azure AI Projects toolbox API may return <c>name</c> and <c>description</c> on
|
||||
/// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API
|
||||
/// rejects at least <c>name</c> with "Unknown parameter: 'tools[0].name'". We strip
|
||||
/// these decoration fields for non-function tools. Function tools keep them since
|
||||
/// <c>name</c> and <c>description</c> are expected parts of the function schema.
|
||||
/// </remarks>
|
||||
internal static AITool SanitizeAndConvert(ProjectsAgentTool tool)
|
||||
{
|
||||
var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J"));
|
||||
var node = JsonNode.Parse(toolJson.ToString());
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var toolType = obj["type"]?.GetValue<string>();
|
||||
|
||||
// Function tools need name/description — don't strip
|
||||
if (toolType is "function" or "custom")
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
// Strip decoration fields that the Responses API rejects
|
||||
bool modified = false;
|
||||
modified |= obj.Remove("name");
|
||||
modified |= obj.Remove("description");
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
return ((ResponseTool)tool).AsAITool();
|
||||
}
|
||||
|
||||
var sanitizedJson = obj.ToJsonString();
|
||||
var sanitizedTool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString(sanitizedJson))!;
|
||||
return sanitizedTool.AsAITool();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionAsync(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
string name,
|
||||
string? version,
|
||||
AgentAdministrationClientOptions? clientOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Throw.IfNull(projectEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
Throw.IfNullOrWhitespace(name);
|
||||
|
||||
var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions);
|
||||
return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static AgentToolboxes CreateToolboxClient(
|
||||
Uri projectEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AgentAdministrationClientOptions? clientOptions = null)
|
||||
{
|
||||
clientOptions ??= new AgentAdministrationClientOptions();
|
||||
var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions);
|
||||
return adminClient.GetAgentToolboxes();
|
||||
}
|
||||
|
||||
internal static async Task<ToolboxVersion> GetToolboxVersionCoreAsync(
|
||||
AgentToolboxes toolboxClient,
|
||||
string name,
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (version is null)
|
||||
{
|
||||
var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
version = record.Value.DefaultVersion
|
||||
?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version.");
|
||||
}
|
||||
|
||||
var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
|
||||
@@ -42,23 +42,15 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = TrySnapshot(options);
|
||||
if (snapshot is null)
|
||||
if (snapshot is not null)
|
||||
{
|
||||
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
// AsyncLocal mutations made inside an awaited async method do not leak back to the
|
||||
// caller after the method returns, so we do not need an explicit restore step here.
|
||||
// See ClientHeadersScope remarks.
|
||||
ClientHeadersScope.Current = snapshot;
|
||||
}
|
||||
|
||||
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
|
||||
|
||||
async Task<AgentResponse> RunAsyncCoreAsync(
|
||||
IEnumerable<ChatMessage> innerMessages,
|
||||
AgentSession? innerSession,
|
||||
AgentRunOptions? innerOptions,
|
||||
Dictionary<string, string> innerSnapshot,
|
||||
CancellationToken innerCt)
|
||||
{
|
||||
using var _ = ClientHeadersScope.Push(innerSnapshot);
|
||||
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
|
||||
}
|
||||
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -69,7 +61,10 @@ internal sealed class ClientHeadersAgent : DelegatingAIAgent
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = TrySnapshot(options);
|
||||
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
|
||||
if (snapshot is not null)
|
||||
{
|
||||
ClientHeadersScope.Current = snapshot;
|
||||
}
|
||||
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
|
||||
@@ -11,39 +11,31 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
|
||||
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
|
||||
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
|
||||
/// for nested or sequential per-call scopes on the same async flow.
|
||||
/// <para>
|
||||
/// <see cref="AsyncLocal{T}"/> propagates the value forward into every <c>await</c> on the same
|
||||
/// async flow, but mutations made inside an awaited <c>async</c> method do <em>not</em> leak back
|
||||
/// to the caller after the method returns. This means a method that assigns
|
||||
/// <see cref="Current"/> at the top and then awaits inner work does not need any explicit
|
||||
/// restoration step: the runtime restores the caller's view of the AsyncLocal automatically when
|
||||
/// the method's task completes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting <see cref="Current"/> from synchronous code, however, will leak to the caller because
|
||||
/// no async-method boundary is crossed. All Agent Framework call sites of this carrier are
|
||||
/// inside <c>async</c> methods (<see cref="ClientHeadersAgent"/>), so the natural restoration
|
||||
/// suffices for our needs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ClientHeadersScope
|
||||
{
|
||||
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
|
||||
|
||||
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
|
||||
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
|
||||
/// Gets or sets the per-async-flow client-header snapshot read by <see cref="ClientHeadersPolicy"/>.
|
||||
/// </summary>
|
||||
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
|
||||
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
|
||||
public static IReadOnlyDictionary<string, string>? Current
|
||||
{
|
||||
var previous = s_current.Value;
|
||||
s_current.Value = headers;
|
||||
return new Scope(previous);
|
||||
}
|
||||
|
||||
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
|
||||
internal readonly struct Scope : System.IDisposable
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string>? _previous;
|
||||
|
||||
internal Scope(IReadOnlyDictionary<string, string>? previous)
|
||||
{
|
||||
this._previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose() => s_current.Value = this._previous;
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
@@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
/// <summary>
|
||||
/// Default OAuth scope for the Azure AI resource. Matches the scope used by
|
||||
/// <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is
|
||||
/// accepted by the Foundry control plane.
|
||||
/// </summary>
|
||||
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
|
||||
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
|
||||
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
|
||||
/// does not build a full <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient? _aiProjectClient;
|
||||
|
||||
/// <summary>
|
||||
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
|
||||
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
|
||||
/// In agent-endpoint mode this is built directly from the project root derived from the
|
||||
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
|
||||
/// <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
private readonly ProjectOpenAIClient _projectOpenAIClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
|
||||
@@ -72,30 +94,49 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
out var aiProjectClient))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific endpoint.
|
||||
/// </summary>
|
||||
/// <param name="agentEndpoint">The agent-specific endpoint URI (must contain the agent name in the path).</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">Optional configuration options for the <see cref="AIProjectClient"/>.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
|
||||
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
|
||||
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
|
||||
/// </list>
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
|
||||
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agentEndpoint"/> or <paramref name="credential"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
/// <remarks>
|
||||
/// This is the lightweight constructor for invoking an existing Foundry hosted agent when the
|
||||
/// caller already has the per-agent endpoint URL. It populates <see cref="ChatClientAgentOptions.Id"/>
|
||||
/// and <see cref="ChatClientAgentOptions.Name"/> from the agent name parsed out of the endpoint
|
||||
/// path; <c>Description</c>, <c>Instructions</c>, <c>Temperature</c>, and <c>TopP</c> are not
|
||||
/// populated. Callers that need those fields hydrated from server-side state should use
|
||||
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentVersion)</c> or
|
||||
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentRecord)</c> instead.
|
||||
/// </remarks>
|
||||
public FoundryAgent(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AIProjectClientOptions? clientOptions = null,
|
||||
ProjectOpenAIClientOptions? clientOptions = null,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromEndpoint(
|
||||
CreateProjectClient(agentEndpoint, credential, clientOptions),
|
||||
agentEndpoint, tools, clientFactory, services,
|
||||
out var aiProjectClient))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -105,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -137,9 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
|
||||
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversationsClient = this._aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectConversationsClient();
|
||||
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -161,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return this._aiProjectClient;
|
||||
}
|
||||
|
||||
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
|
||||
{
|
||||
return this._projectOpenAIClient;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
@@ -238,47 +283,181 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ClientHeadersPolicy.Instance,
|
||||
System.ClientModel.Primitives.PipelinePosition.PerCall);
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
|
||||
return new ClientHeadersAgent(innerAgent);
|
||||
}
|
||||
|
||||
private static AIAgent CreateInnerAgentFromEndpoint(
|
||||
AIProjectClient aiProjectClient,
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
|
||||
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
|
||||
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
|
||||
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
|
||||
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
|
||||
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
|
||||
/// client with <c>Endpoint</c> and
|
||||
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
|
||||
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
|
||||
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
IServiceProvider? services)
|
||||
{
|
||||
outClient = aiProjectClient;
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/');
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Name = agentReference.Name,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
|
||||
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
|
||||
|
||||
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Id = agentName,
|
||||
Name = agentName,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
|
||||
/// constructor by deriving the project root from the supplied agent endpoint and constructing
|
||||
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
|
||||
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
|
||||
/// conversations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
|
||||
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
|
||||
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
|
||||
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
|
||||
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
|
||||
/// user-agent policy is appended last.
|
||||
/// </remarks>
|
||||
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var projectOptions = new ProjectOpenAIClientOptions();
|
||||
if (clientOptions is not null)
|
||||
{
|
||||
if (clientOptions.RetryPolicy is not null)
|
||||
{
|
||||
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
|
||||
}
|
||||
|
||||
if (clientOptions.NetworkTimeout is not null)
|
||||
{
|
||||
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
|
||||
}
|
||||
|
||||
if (clientOptions.Transport is not null)
|
||||
{
|
||||
projectOptions.Transport = clientOptions.Transport;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
|
||||
{
|
||||
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
|
||||
}
|
||||
}
|
||||
|
||||
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
/// and returns the agent name and the derived project-root URI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Single source of truth for both agent-name extraction and project-root derivation.
|
||||
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
|
||||
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
|
||||
{
|
||||
Throw.IfNull(endpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
clientOptions ??= new AIProjectClientOptions();
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall);
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
return new AIProjectClient(endpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<!-- Preview while we depend on Azure.AI.Projects 2.1.0-beta.1 for hosted-agent routing
|
||||
(ProjectOpenAIClientOptions.AgentName, the (AuthenticationPolicy, options) ctor, and
|
||||
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
|
||||
ships a stable 2.1.0. -->
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -54,6 +54,9 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
private bool _autonomousMode;
|
||||
private string? _autonomousModePrompt;
|
||||
private int? _autonomousModeTurnLimit;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
|
||||
@@ -142,6 +145,34 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables autonomous mode for all agents in the workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In autonomous mode, when an agent responds without requesting a handoff, it is immediately
|
||||
/// re-invoked with a synthetic user message (the <paramref name="prompt"/>) rather than
|
||||
/// returning control to the user. The agent continues iterating until it requests a handoff
|
||||
/// or the <paramref name="turnLimit"/> is reached. After the turn limit is exceeded, control
|
||||
/// is returned to the user as in the default human-in-the-loop behavior.
|
||||
/// </remarks>
|
||||
/// <param name="prompt">
|
||||
/// The message to inject as a user turn when re-invoking an agent in autonomous mode.
|
||||
/// If <see langword="null"/>, a default prompt is used.
|
||||
/// </param>
|
||||
/// <param name="turnLimit">
|
||||
/// The maximum number of autonomous continuation turns per agent per incoming turn.
|
||||
/// The counter resets at the beginning of each new turn (each incoming <see cref="HandoffState"/>).
|
||||
/// If <see langword="null"/>, the default limit is used.
|
||||
/// </param>
|
||||
/// <returns>The updated builder instance.</returns>
|
||||
public TBuilder EnableAutonomousMode(string? prompt = null, int? turnLimit = null)
|
||||
{
|
||||
this._autonomousMode = true;
|
||||
this._autonomousModePrompt = prompt;
|
||||
this._autonomousModeTurnLimit = turnLimit;
|
||||
return (TBuilder)this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds handoff relationships from a source agent to one or more target agents.
|
||||
/// </summary>
|
||||
@@ -247,7 +278,10 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
|
||||
this._emitAgentResponseEvents,
|
||||
this._emitAgentResponseUpdateEvents,
|
||||
this._toolCallFilteringBehavior);
|
||||
this._toolCallFilteringBehavior,
|
||||
autonomousMode: this._autonomousMode,
|
||||
autonomousModePrompt: this._autonomousModePrompt,
|
||||
autonomousModeTurnLimit: this._autonomousModeTurnLimit);
|
||||
|
||||
// There are two types of ids being used in this method, and it is critical that we are clear about
|
||||
// which one we are using, and where.
|
||||
|
||||
@@ -15,12 +15,22 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal sealed class HandoffAgentExecutorOptions
|
||||
{
|
||||
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
|
||||
public HandoffAgentExecutorOptions(
|
||||
string? handoffInstructions,
|
||||
bool emitAgentResponseEvents,
|
||||
bool? emitAgentResponseUpdateEvents,
|
||||
HandoffToolCallFilteringBehavior toolCallFilteringBehavior,
|
||||
bool autonomousMode = false,
|
||||
string? autonomousModePrompt = null,
|
||||
int? autonomousModeTurnLimit = null)
|
||||
{
|
||||
this.HandoffInstructions = handoffInstructions;
|
||||
this.EmitAgentResponseEvents = emitAgentResponseEvents;
|
||||
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
|
||||
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
|
||||
this.AutonomousMode = autonomousMode;
|
||||
this.AutonomousModePrompt = autonomousModePrompt ?? HandoffAgentExecutor.DefaultAutonomousModePrompt;
|
||||
this.AutonomousModeTurnLimit = autonomousModeTurnLimit ?? HandoffAgentExecutor.DefaultAutonomousModeTurnLimit;
|
||||
}
|
||||
|
||||
public string? HandoffInstructions { get; set; }
|
||||
@@ -30,6 +40,23 @@ internal sealed class HandoffAgentExecutorOptions
|
||||
public bool? EmitAgentResponseUpdateEvents { get; set; }
|
||||
|
||||
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the agent operates in autonomous mode.
|
||||
/// In autonomous mode, the agent continues responding without user input until a handoff is requested or the turn limit is reached.
|
||||
/// </summary>
|
||||
public bool AutonomousMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the prompt to inject as a user message when continuing in autonomous mode.
|
||||
/// </summary>
|
||||
public string AutonomousModePrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of autonomous turns per incoming turn.
|
||||
/// The counter is reset at the start of every new <see cref="HandoffState"/> turn.
|
||||
/// </summary>
|
||||
public int AutonomousModeTurnLimit { get; set; }
|
||||
}
|
||||
|
||||
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
|
||||
@@ -74,6 +101,12 @@ internal sealed record StateRef<TState>(string Key, string? ScopeName)
|
||||
internal sealed class HandoffAgentExecutor :
|
||||
StatefulExecutor<HandoffAgentHostState, HandoffState>
|
||||
{
|
||||
/// <summary>The default prompt injected as a user message when operating in autonomous mode and no handoff has been requested.</summary>
|
||||
internal const string DefaultAutonomousModePrompt = "User did not respond. Continue assisting autonomously.";
|
||||
|
||||
/// <summary>The default maximum number of autonomous turns before control is returned to the user.</summary>
|
||||
internal const int DefaultAutonomousModeTurnLimit = 50;
|
||||
|
||||
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
|
||||
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
|
||||
|
||||
@@ -87,6 +120,8 @@ internal sealed class HandoffAgentExecutor :
|
||||
private readonly HashSet<string> _handoffFunctionNames = [];
|
||||
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
|
||||
|
||||
private int _autonomousModeTurnCount;
|
||||
|
||||
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
|
||||
HandoffConstants.HandoffSharedStateScope);
|
||||
|
||||
@@ -277,6 +312,38 @@ internal sealed class HandoffAgentExecutor :
|
||||
// happens if we have no outstanding requests.
|
||||
if (!this.HasOutstandingRequests)
|
||||
{
|
||||
// In autonomous mode, if no handoff was requested and we haven't hit the turn limit, continue the agent's
|
||||
// turn by injecting a synthetic user message instead of returning control to the user.
|
||||
if (this._options.AutonomousMode && !result.IsHandoffRequested && this._autonomousModeTurnCount < this._options.AutonomousModeTurnLimit)
|
||||
{
|
||||
ChatMessage autonomousMessage = new(ChatRole.User, this._options.AutonomousModePrompt)
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
int autonomousBookmark = newConversationBookmark;
|
||||
await this._sharedStateRef.InvokeWithStateAsync(
|
||||
(sharedState, ctx, ct) =>
|
||||
{
|
||||
autonomousBookmark = sharedState!.Conversation.AddMessage(autonomousMessage);
|
||||
return new ValueTask();
|
||||
},
|
||||
context,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Increment only after successfully adding the autonomous message to shared state.
|
||||
// This ensures the counter remains accurate if the state write throws an exception.
|
||||
this._autonomousModeTurnCount++;
|
||||
|
||||
return await this.ContinueTurnAsync(
|
||||
state with { ConversationBookmark = autonomousBookmark },
|
||||
[autonomousMessage],
|
||||
context,
|
||||
cancellationToken,
|
||||
skipAddIncoming: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
|
||||
|
||||
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
|
||||
@@ -321,6 +388,11 @@ internal sealed class HandoffAgentExecutor :
|
||||
|
||||
state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark };
|
||||
|
||||
// Reset the autonomous turn counter at the start of each new HandoffState turn so that
|
||||
// the limit is applied fresh for every incoming message, regardless of how the previous
|
||||
// turn ended (e.g. outstanding external requests that prevented an earlier reset).
|
||||
this._autonomousModeTurnCount = 0;
|
||||
|
||||
return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -329,40 +329,18 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
this._logger.LogAgentChatClientInvokedStreamingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
|
||||
|
||||
bool hasUpdates;
|
||||
// Ensure the inner enumerator is always disposed, even if the consumer breaks out early
|
||||
// (e.g. ToolApprovalAgent does `yield break` after emitting an approval request). Without
|
||||
// this, downstream decorators like PerServiceCallChatHistoryPersistingChatClient would be
|
||||
// left suspended at `yield return`, never running their finally blocks, and any in-flight
|
||||
// FunctionResultContent / FunctionCallContent state would not be persisted before the next
|
||||
// turn, leaving the next request to the model with dangling tool calls.
|
||||
try
|
||||
{
|
||||
// Ensure we start the streaming request
|
||||
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = responseUpdatesEnumerator.Current;
|
||||
if (update is not null)
|
||||
{
|
||||
update.AuthorName ??= this.Name;
|
||||
|
||||
responseUpdates.Add(update);
|
||||
|
||||
yield return new(update)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
|
||||
};
|
||||
}
|
||||
|
||||
bool hasUpdates;
|
||||
try
|
||||
{
|
||||
// Re-ensure the run context has the resolved session before each MoveNextAsync.
|
||||
// The base class RunStreamingAsync restores the original context (potentially with
|
||||
// null session) after each yield, so we must re-establish it for the decorator.
|
||||
EnsureRunContextHasSession(safeSession);
|
||||
// Ensure we start the streaming request
|
||||
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -370,20 +348,55 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = responseUpdatesEnumerator.Current;
|
||||
if (update is not null)
|
||||
{
|
||||
update.AuthorName ??= this.Name;
|
||||
|
||||
responseUpdates.Add(update);
|
||||
|
||||
yield return new(update)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Re-ensure the run context has the resolved session before each MoveNextAsync.
|
||||
// The base class RunStreamingAsync restores the original context (potentially with
|
||||
// null session) after each yield, so we must re-establish it for the decorator.
|
||||
EnsureRunContextHasSession(safeSession);
|
||||
hasUpdates = await responseUpdatesEnumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this.NotifyProvidersOfFailureAtEndOfRunAsync(safeSession, ex, GetInputMessages(inputMessagesForChatClient, continuationToken), chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
var chatResponse = responseUpdates.ToChatResponse();
|
||||
|
||||
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
|
||||
|
||||
// We can derive the type of supported session from whether we have a conversation id,
|
||||
// so let's update it and set the conversation id for the service session case.
|
||||
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
|
||||
|
||||
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
|
||||
// When resuming from a continuation token or using background responses, force notification
|
||||
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
|
||||
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await responseUpdatesEnumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var chatResponse = responseUpdates.ToChatResponse();
|
||||
|
||||
var forceEndOfRunPersistence = continuationToken is not null || chatOptions?.AllowBackgroundResponses is true;
|
||||
|
||||
// We can derive the type of supported session from whether we have a conversation id,
|
||||
// so let's update it and set the conversation id for the service session case.
|
||||
this.UpdateSessionConversationIdAtEndOfRun(safeSession, chatResponse.ConversationId, cancellationToken, forceUpdate: forceEndOfRunPersistence);
|
||||
|
||||
// Notify providers of all new messages unless persistence is handled per-service-call by the decorator.
|
||||
// When resuming from a continuation token or using background responses, force notification
|
||||
// to send the combined data (per-service-call persistence is unreliable for these scenarios).
|
||||
await this.NotifyProvidersOfNewMessagesAtEndOfRunAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken, forceNotify: forceEndOfRunPersistence).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -151,6 +151,36 @@ public sealed class ChatClientAgentOptions
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include a <see cref="MessageInjectingChatClient"/>
|
||||
/// in the chat client pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set to <see langword="true"/>, a <see cref="MessageInjectingChatClient"/> is added to the pipeline
|
||||
/// between the <see cref="FunctionInvokingChatClient"/> and the inner client. This enables external code
|
||||
/// (such as tool delegates) to inject messages into the function execution loop via the
|
||||
/// <see cref="MessageInjectingChatClient"/> class, which can be resolved from the chat client using
|
||||
/// <c>GetService<MessageInjectingChatClient>()</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This setting can be used independently of <see cref="RequirePerServiceCallChatHistoryPersistence"/>,
|
||||
/// however it is recommended to also enable per-service-call persistence when using message injection
|
||||
/// so that injected messages are persisted to chat history between service calls.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
|
||||
/// <see cref="EnableMessageInjection"/> to <see langword="true"/>, ensure that your custom chat client stack
|
||||
/// includes a <see cref="MessageInjectingChatClient"/>. You can add one manually via the
|
||||
/// <see cref="ChatClientBuilderExtensions.UseMessageInjection"/> extension method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Default is <see langword="false"/>.
|
||||
/// </value>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public bool EnableMessageInjection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
||||
/// </summary>
|
||||
@@ -168,5 +198,6 @@ public sealed class ChatClientAgentOptions
|
||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
||||
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
|
||||
EnableMessageInjection = this.EnableMessageInjection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,4 +114,38 @@ public static class ChatClientBuilderExtensions
|
||||
{
|
||||
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a <see cref="MessageInjectingChatClient"/> to the chat client pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This decorator enables external code (such as tool delegates) to inject messages into the function
|
||||
/// execution loop. It should be positioned between the <see cref="FunctionInvokingChatClient"/> and
|
||||
/// the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
|
||||
/// in the pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="MessageInjectingChatClient"/> can be retrieved from the chat client via
|
||||
/// <c>GetService<MessageInjectingChatClient></c> to enqueue messages from tool delegates or other code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This extension method is intended for use with custom chat client stacks when
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
|
||||
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline when
|
||||
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
|
||||
/// exception if used in any other stack.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder)
|
||||
{
|
||||
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,21 @@ public static class ChatClientExtensions
|
||||
});
|
||||
}
|
||||
|
||||
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
|
||||
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
|
||||
// MessageInjectingChatClient is injected when EnableMessageInjection is enabled.
|
||||
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client.
|
||||
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
|
||||
// By adding our decorator second, the resulting pipeline is:
|
||||
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
|
||||
// This allows the decorator to simulate service-stored chat history by loading history before
|
||||
// each service call, persisting after each call, and returning a sentinel ConversationId.
|
||||
// MessageInjectingChatClient enables injecting messages during the function loop and looping when needed.
|
||||
if (options?.EnableMessageInjection is true)
|
||||
{
|
||||
chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient));
|
||||
}
|
||||
|
||||
// PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled.
|
||||
// It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client.
|
||||
// The resulting pipeline is:
|
||||
// FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient
|
||||
// PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history
|
||||
// before each service call, persisting after each call, and returning a sentinel ConversationId.
|
||||
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
|
||||
{
|
||||
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that supports injecting messages into the function execution loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This decorator enables external code (such as tool delegates) to enqueue messages that will be
|
||||
/// sent to the underlying model at the next opportunity. It sits between the <see cref="FunctionInvokingChatClient"/>
|
||||
/// and the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
|
||||
/// in a <see cref="ChatClientAgent"/> pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The injected messages queue is stored per-session in the <see cref="AgentSession.StateBag"/>, ensuring
|
||||
/// isolation between concurrent sessions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// After each service call, if no actionable <see cref="FunctionCallContent"/> is returned but injected
|
||||
/// messages are pending, the decorator loops internally and calls the inner client again with the new
|
||||
/// messages. When actionable function calls are present, control returns to the parent
|
||||
/// <see cref="FunctionInvokingChatClient"/> loop.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
|
||||
/// current session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
|
||||
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
|
||||
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
|
||||
/// method is called.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public sealed class MessageInjectingChatClient : DelegatingChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// The key used to store the pending injected messages queue in the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageInjectingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
|
||||
public MessageInjectingChatClient(IChatClient innerClient)
|
||||
: base(innerClient)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = GetRequiredSession();
|
||||
var queue = GetOrCreateQueue(session);
|
||||
|
||||
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? messages.ToList());
|
||||
|
||||
// Loop to process injected messages: after each service call, if no actionable function calls
|
||||
// are pending but new messages have been injected into the queue, we call the service again
|
||||
// so the model can process them. The loop exits when the response contains actionable
|
||||
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
|
||||
while (true)
|
||||
{
|
||||
var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
|
||||
// loop will iterate — return immediately so it can process them.
|
||||
if (HasActionableFunctionCalls(response.Messages))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// No actionable function calls. If there are pending injected messages, loop again
|
||||
// to send them to the service. Otherwise, we're done.
|
||||
bool queueEmpty;
|
||||
lock (queue)
|
||||
{
|
||||
queueEmpty = queue.Count == 0;
|
||||
}
|
||||
|
||||
if (queueEmpty)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// Propagate any ConversationId returned by the service so subsequent iterations
|
||||
// continue within the same conversation.
|
||||
UpdateOptionsForNextIteration(ref options, response.ConversationId);
|
||||
|
||||
newMessages = DrainInjectedMessages(queue, Array.Empty<ChatMessage>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = GetRequiredSession();
|
||||
var queue = GetOrCreateQueue(session);
|
||||
|
||||
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? messages.ToList());
|
||||
|
||||
// Loop to process injected messages: after each service call, if no actionable function calls
|
||||
// are pending but new messages have been injected into the queue, we call the service again
|
||||
// so the model can process them. The loop exits when the response contains actionable
|
||||
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
|
||||
while (true)
|
||||
{
|
||||
bool hasActionableFunctionCalls = false;
|
||||
string? lastConversationId = null;
|
||||
|
||||
var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
while (await enumerator.MoveNextAsync().ConfigureAwait(false))
|
||||
{
|
||||
var update = enumerator.Current;
|
||||
|
||||
// Check each update for actionable function call content as it streams through.
|
||||
if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update))
|
||||
{
|
||||
hasActionableFunctionCalls = true;
|
||||
}
|
||||
|
||||
// Track the latest ConversationId from the stream.
|
||||
if (update.ConversationId is not null)
|
||||
{
|
||||
lastConversationId = update.ConversationId;
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
|
||||
// loop will iterate — return immediately so it can process them.
|
||||
if (hasActionableFunctionCalls)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// No actionable function calls. If there are pending injected messages, loop again
|
||||
// to send them to the service. Otherwise, we're done.
|
||||
bool queueEmpty;
|
||||
lock (queue)
|
||||
{
|
||||
queueEmpty = queue.Count == 0;
|
||||
}
|
||||
|
||||
if (queueEmpty)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Propagate any ConversationId returned by the service so subsequent iterations
|
||||
// continue within the same conversation.
|
||||
UpdateOptionsForNextIteration(ref options, lastConversationId);
|
||||
|
||||
newMessages = DrainInjectedMessages(queue, Array.Empty<ChatMessage>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues one or more messages to be used at the next opportunity.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is thread-safe and can be called concurrently from tool delegates or other code
|
||||
/// while the function execution loop is in progress. The enqueued messages will be picked up
|
||||
/// at the next opportunity.
|
||||
/// </remarks>
|
||||
/// <param name="session">The agent session to enqueue messages for.</param>
|
||||
/// <param name="messages">The messages to enqueue.</param>
|
||||
public void EnqueueMessages(AgentSession session, IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
var queue = GetOrCreateQueue(session);
|
||||
|
||||
lock (queue)
|
||||
{
|
||||
foreach (var message in messages)
|
||||
{
|
||||
queue.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the pending injected messages queue from the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
private static List<ChatMessage> GetOrCreateQueue(AgentSession session)
|
||||
{
|
||||
if (session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue))
|
||||
{
|
||||
return queue!;
|
||||
}
|
||||
|
||||
var newQueue = new List<ChatMessage>();
|
||||
session.StateBag.SetValue(PendingMessagesStateKey, newQueue);
|
||||
return newQueue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current <see cref="AgentSession"/> from the run context.
|
||||
/// </summary>
|
||||
private static AgentSession GetRequiredSession()
|
||||
{
|
||||
var runContext = AIAgent.CurrentRunContext
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(MessageInjectingChatClient)} can only be used within the context of a running AIAgent. " +
|
||||
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
|
||||
|
||||
return runContext.Session
|
||||
?? throw new InvalidOperationException(
|
||||
$"{nameof(MessageInjectingChatClient)} requires a session. " +
|
||||
"The current run context does not have a session.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains all pending injected messages from the queue and returns a new list combining
|
||||
/// the original messages with the drained messages. The original list is never modified.
|
||||
/// </summary>
|
||||
private static IList<ChatMessage> DrainInjectedMessages(List<ChatMessage> queue, IList<ChatMessage> newMessages)
|
||||
{
|
||||
lock (queue)
|
||||
{
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
return newMessages;
|
||||
}
|
||||
|
||||
var combined = new List<ChatMessage>(newMessages);
|
||||
combined.AddRange(queue);
|
||||
queue.Clear();
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether any message in the list contains a <see cref="FunctionCallContent"/>
|
||||
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
|
||||
/// </summary>
|
||||
private static bool HasActionableFunctionCalls(IList<ChatMessage> responseMessages)
|
||||
{
|
||||
for (int i = 0; i < responseMessages.Count; i++)
|
||||
{
|
||||
var contents = responseMessages[i].Contents;
|
||||
for (int j = 0; j < contents.Count; j++)
|
||||
{
|
||||
if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a streaming update contains a <see cref="FunctionCallContent"/>
|
||||
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
|
||||
/// </summary>
|
||||
private static bool HasActionableFunctionCalls(ChatResponseUpdate update)
|
||||
{
|
||||
var contents = update.Contents;
|
||||
for (int i = 0; i < contents.Count; i++)
|
||||
{
|
||||
if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Propagates the <paramref name="conversationId"/> from the service response into
|
||||
/// <paramref name="options"/> so that subsequent loop iterations continue within the
|
||||
/// same conversation. Clones <paramref name="options"/> before mutating to avoid
|
||||
/// affecting the caller's instance.
|
||||
/// </summary>
|
||||
private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
if (conversationId is not null)
|
||||
{
|
||||
options = new() { ConversationId = conversationId };
|
||||
}
|
||||
}
|
||||
else if (options.ConversationId != conversationId)
|
||||
{
|
||||
options = options.Clone();
|
||||
options.ConversationId = conversationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
-29
@@ -152,7 +152,14 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|
||||
|| options?.AllowBackgroundResponses is true;
|
||||
bool skipSimulation = isServiceManaged || isContinuationOrBackground;
|
||||
|
||||
var newMessages = messages as IList<ChatMessage> ?? messages.ToList();
|
||||
// Snapshot the input messages into a private list. The caller (typically
|
||||
// FunctionInvokingChatClient) reuses a single mutable buffer across iterations,
|
||||
// and the streaming path can defer persistence until after the caller has already
|
||||
// mutated that buffer for the next iteration (e.g. on the cooperative early-exit
|
||||
// path NotifyProvidersOfEarlyExitInputAsync). Aliasing the caller's list would
|
||||
// then cause us to persist the wrong messages — losing FunctionResultContent and
|
||||
// corrupting history with dangling FunctionCallContent.
|
||||
var newMessages = messages.ToList();
|
||||
|
||||
// When simulating, load history and prepend it. When the service manages
|
||||
// history (real ConversationId) or this is a continuation/background run,
|
||||
@@ -174,45 +181,83 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|
||||
throw;
|
||||
}
|
||||
|
||||
bool hasUpdates;
|
||||
bool loopExitedNormally = false;
|
||||
bool serviceErrorOccurred = false;
|
||||
try
|
||||
{
|
||||
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = enumerator.Current;
|
||||
responseUpdates.Add(update.Clone());
|
||||
|
||||
// If the service returned a real ConversationId on any update, remember that.
|
||||
// Otherwise stamp our sentinel so FICC treats this as service-managed —
|
||||
// unless this is a continuation/background run where the agent handles everything.
|
||||
if (!string.IsNullOrEmpty(update.ConversationId))
|
||||
{
|
||||
isServiceManaged = true;
|
||||
}
|
||||
else if (!skipSimulation)
|
||||
{
|
||||
update.ConversationId = LocalHistoryConversationId;
|
||||
}
|
||||
|
||||
yield return update;
|
||||
|
||||
bool hasUpdates;
|
||||
try
|
||||
{
|
||||
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
serviceErrorOccurred = true;
|
||||
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
while (hasUpdates)
|
||||
{
|
||||
var update = enumerator.Current;
|
||||
responseUpdates.Add(update.Clone());
|
||||
|
||||
// If the service returned a real ConversationId on any update, remember that.
|
||||
// Otherwise stamp our sentinel so FICC treats this as service-managed —
|
||||
// unless this is a continuation/background run where the agent handles everything.
|
||||
if (!string.IsNullOrEmpty(update.ConversationId))
|
||||
{
|
||||
isServiceManaged = true;
|
||||
}
|
||||
else if (!skipSimulation)
|
||||
{
|
||||
update.ConversationId = LocalHistoryConversationId;
|
||||
}
|
||||
|
||||
yield return update;
|
||||
|
||||
try
|
||||
{
|
||||
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
serviceErrorOccurred = true;
|
||||
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
loopExitedNormally = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// If the iterator was disposed by the consumer before completing — e.g.
|
||||
// ToolApprovalAgent does `yield break` after emitting an approval request — persist
|
||||
// the input messages so that any in-flight FunctionResultContent paired with
|
||||
// previously-persisted FunctionCallContent is not lost between turns. We only do
|
||||
// this on the cooperative-pause path; service errors deliberately do NOT persist
|
||||
// input messages (history of failed calls is the caller's responsibility, e.g.
|
||||
// by retrying or starting from an earlier point).
|
||||
if (!loopExitedNormally && !serviceErrorOccurred)
|
||||
{
|
||||
// Prefer the original cancellation token so cleanup remains responsive; fall
|
||||
// back to None only if the caller's token has already been canceled (otherwise
|
||||
// the notify call would observe the cancellation, throw, and mask the
|
||||
// original early-exit reason).
|
||||
var persistToken = cancellationToken.IsCancellationRequested ? CancellationToken.None : cancellationToken;
|
||||
try
|
||||
{
|
||||
await NotifyProvidersOfEarlyExitInputAsync(agent, session, newMessages, options, persistToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort persistence; swallow to avoid masking the original exit reason.
|
||||
}
|
||||
}
|
||||
|
||||
// Always dispose the underlying enumerator on every exit path (normal completion,
|
||||
// exception, or early consumer disposal) to release the underlying HTTP/stream.
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var chatResponse = responseUpdates.ToChatResponse();
|
||||
@@ -236,6 +281,30 @@ internal sealed class PerServiceCallChatHistoryPersistingChatClient : Delegating
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies <see cref="ChatHistoryProvider"/>s of the input messages only (no response
|
||||
/// messages) on the cooperative early-exit path — e.g. when <c>ToolApprovalAgent</c>
|
||||
/// does <c>yield break</c> after emitting an approval request. This ensures any
|
||||
/// in-flight <see cref="FunctionResultContent"/> paired with previously-persisted
|
||||
/// <see cref="FunctionCallContent"/> is not orphaned in the persisted chat history.
|
||||
/// The notification is routed through the same success channel used at the end of a
|
||||
/// normal run; the providers themselves decide how (or whether) to persist.
|
||||
/// </summary>
|
||||
private static async Task NotifyProvidersOfEarlyExitInputAsync(
|
||||
ChatClientAgent agent,
|
||||
ChatClientAgentSession session,
|
||||
List<ChatMessage> newMessages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (newMessages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, [], options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the sentinel <see cref="LocalHistoryConversationId"/> on the response and session
|
||||
/// so that <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed.
|
||||
|
||||
@@ -21,6 +21,10 @@ internal static class TestSettings
|
||||
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
|
||||
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
|
||||
|
||||
// Azure AI Search (Foundry.Hosting integration tests, RAG scenario)
|
||||
public const string AzureSearchEndpoint = "AZURE_SEARCH_ENDPOINT";
|
||||
public const string AzureSearchIndexName = "AZURE_SEARCH_INDEX_NAME";
|
||||
|
||||
// Foundry Hosted Agents (Foundry.Hosting integration tests)
|
||||
public const string FoundryHostingItImage = "IT_HOSTED_AGENT_IMAGE";
|
||||
|
||||
|
||||
+1
@@ -33,6 +33,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Azure.Search.Documents;
|
||||
using Azure.Search.Documents.Models;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -29,9 +32,10 @@ AIAgent agent = scenario switch
|
||||
"happy-path" => CreateHappyPathAgent(projectClient, deployment),
|
||||
"tool-calling" => CreateToolCallingAgent(projectClient, deployment),
|
||||
"tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment),
|
||||
"toolbox" => CreateToolboxAgent(projectClient, deployment),
|
||||
"mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment),
|
||||
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
|
||||
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
|
||||
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
|
||||
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
|
||||
};
|
||||
|
||||
@@ -79,17 +83,6 @@ static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string dep
|
||||
AIFunctionFactory.Create(SendEmail)
|
||||
]);
|
||||
|
||||
static AIAgent CreateToolboxAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire Foundry toolbox host once API surface is finalized for hosted agents.
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: "You are a toolbox enabled assistant. Use GetEnvironmentName when asked.",
|
||||
name: "toolbox-agent",
|
||||
description: "Toolbox test agent (placeholder).",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetEnvironmentName)
|
||||
]);
|
||||
|
||||
static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) =>
|
||||
// TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp.
|
||||
client.AsAIAgent(
|
||||
@@ -106,6 +99,86 @@ static AIAgent CreateCustomStorageAgent(AIProjectClient client, string deploymen
|
||||
name: "custom-storage-agent",
|
||||
description: "Custom storage test agent (placeholder).");
|
||||
|
||||
static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployment)
|
||||
{
|
||||
// The fixture (AzureSearchRagHostedAgentFixture) injects AZURE_SEARCH_ENDPOINT and
|
||||
// AZURE_SEARCH_INDEX_NAME into the hosted agent definition. The index is provisioned
|
||||
// out of band (see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md for the
|
||||
// required schema and seed content); the container only needs read access. The
|
||||
// agent's managed identity must hold 'Search Index Data Reader' on the search service
|
||||
// scope.
|
||||
var searchEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_SEARCH_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_SEARCH_ENDPOINT is not set for IT_SCENARIO=azure-search-rag."));
|
||||
var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
|
||||
?? throw new InvalidOperationException("AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-rag.");
|
||||
|
||||
var searchClient = new SearchClient(searchEndpoint, indexName, new DefaultAzureCredential());
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 6,
|
||||
};
|
||||
|
||||
return client.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "azure-search-rag-agent",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = deployment,
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. " +
|
||||
"Answer questions using the provided context and cite the source document when available.",
|
||||
},
|
||||
AIContextProviders = [new TextSearchProvider(CreateAzureSearchAdapter(searchClient), options)]
|
||||
});
|
||||
}
|
||||
|
||||
static Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>>
|
||||
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
|
||||
async (query, cancellationToken) =>
|
||||
{
|
||||
var searchOptions = new SearchOptions { Size = top };
|
||||
Response<SearchResults<SearchDocument>> response =
|
||||
await client.SearchAsync<SearchDocument>(query, searchOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<TextSearchProvider.TextSearchResult>();
|
||||
await foreach (SearchResult<SearchDocument> hit in response.Value.GetResultsAsync().WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new TextSearchProvider.TextSearchResult
|
||||
{
|
||||
SourceName = hit.Document.TryGetValue("sourceName", out var name) ? name?.ToString() ?? string.Empty : string.Empty,
|
||||
SourceLink = hit.Document.TryGetValue("sourceLink", out var link) ? link?.ToString() ?? string.Empty : string.Empty,
|
||||
Text = hit.Document.TryGetValue("content", out var content) ? content?.ToString() ?? string.Empty : string.Empty,
|
||||
RawRepresentation = hit
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
// session-files scenario: agent reads files from $HOME inside the per-session sandbox volume.
|
||||
// Mirrors the dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Files sample.
|
||||
static AIAgent CreateSessionFilesAgent(AIProjectClient client, string deployment) =>
|
||||
client.AsAIAgent(
|
||||
model: deployment,
|
||||
instructions: """
|
||||
You are a friendly assistant that helps users inspect and summarise
|
||||
files stored in the session sandbox at $HOME.
|
||||
|
||||
Always answer file-related questions by calling the available tools
|
||||
(GetHomeDirectory, ListFiles, ReadFile). Do not guess file paths or
|
||||
contents — read the file before answering.
|
||||
|
||||
Quote numbers and figures verbatim from the file rather than
|
||||
paraphrasing them.
|
||||
""",
|
||||
name: "session-files-agent",
|
||||
description: "Reads files from the per-session $HOME volume.",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetHomeDirectory),
|
||||
AIFunctionFactory.Create(ListFiles),
|
||||
AIFunctionFactory.Create(ReadFile)
|
||||
]);
|
||||
|
||||
[Description("Returns the current UTC date and time as an ISO 8601 string.")]
|
||||
static string GetUtcNow() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
@@ -118,5 +191,73 @@ static string SendEmail(
|
||||
[Description("Email subject")] string subject) =>
|
||||
$"Email sent to {to} with subject '{subject}'.";
|
||||
|
||||
[Description("Returns the deployment environment name.")]
|
||||
static string GetEnvironmentName() => "integration-test";
|
||||
// session-files tools: resolve paths against $HOME (the per-session sandbox volume).
|
||||
[Description("Get the absolute path of the session home directory ($HOME).")]
|
||||
static string GetHomeDirectory() => SessionHome();
|
||||
|
||||
[Description("List files and directories under the given path inside the session sandbox. Pass an empty string to list $HOME.")]
|
||||
static string[] ListFiles(
|
||||
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFileSystemEntries(ResolveSessionPath(path)).ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return [$"Error listing '{path}': {ex.Message}"];
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Read the full text contents of a file inside the session sandbox.")]
|
||||
static string ReadFile(
|
||||
[Description("Path relative to $HOME. Absolute paths and traversals (..) are rejected.")] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(ResolveSessionPath(path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error reading '{path}': {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
static string SessionHome() =>
|
||||
Environment.GetEnvironmentVariable("HOME")
|
||||
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
// Resolve a caller-supplied path against $HOME, rejecting absolute paths and traversal segments
|
||||
// so that the model cannot read or list arbitrary container files via the ReadFile/ListFiles
|
||||
// tools (defense-in-depth against indirect prompt injection). Mirrors the canonicalize +
|
||||
// startsWith($HOME) pattern used by FileSystemAgentFileStore.ResolveSafePath.
|
||||
static string ResolveSessionPath(string path)
|
||||
{
|
||||
string home = SessionHome();
|
||||
string homeFull = Path.GetFullPath(home);
|
||||
string homePrefix = homeFull.EndsWith(Path.DirectorySeparatorChar)
|
||||
? homeFull
|
||||
: homeFull + Path.DirectorySeparatorChar;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return homeFull;
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(path))
|
||||
{
|
||||
throw new ArgumentException($"Absolute paths are not allowed: '{path}'.", nameof(path));
|
||||
}
|
||||
|
||||
string combined = Path.Combine(homeFull, path);
|
||||
string fullPath = Path.GetFullPath(combined);
|
||||
|
||||
if (!fullPath.Equals(homeFull, StringComparison.Ordinal) &&
|
||||
!fullPath.StartsWith(homePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Path '{path}' resolves outside the session sandbox.", nameof(path));
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End to end RAG integration tests against a hosted agent backed by Azure AI Search.
|
||||
/// The hosted agent runs the test container with <c>IT_SCENARIO=azure-search-rag</c>, which
|
||||
/// wires <see cref="TextSearchProvider"/> over a real <c>SearchClient</c> against the
|
||||
/// pre-seeded Contoso Outdoors index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each test asks for a unique <c>*-CANARY-*</c> token that exists ONLY in the seeded
|
||||
/// document. The model cannot fabricate these tokens from its training data, so a passing
|
||||
/// assertion is proof the agent retrieved the seeded document via Azure AI Search rather
|
||||
/// than answering from general knowledge.
|
||||
/// </remarks>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class AzureSearchRagHostedAgentTests(AzureSearchRagHostedAgentFixture fixture)
|
||||
: IClassFixture<AzureSearchRagHostedAgentFixture>
|
||||
{
|
||||
private readonly AzureSearchRagHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task RagAnswer_CitesSeededReturnPolicy_WhenAskedAboutReturnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act: ask about the canary SKU embedded in the seeded Return Policy doc. The
|
||||
// canary token (TR-CANARY-7821) is unfakeable - it does not exist in any model
|
||||
// training data, so its presence in the answer is proof the agent retrieved
|
||||
// the seeded document via the Azure AI Search adapter.
|
||||
var response = await agent.RunAsync(
|
||||
"What item code do I get with my return? Cite the source.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("TR-CANARY-7821", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RagAnswer_CitesShippingGuide_WhenAskedAboutShippingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act: canary promo code (SHIP-CANARY-4493) is unique to the seeded Shipping
|
||||
// Guide doc. Its presence proves the answer was grounded in retrieved content.
|
||||
var response = await agent.RunAsync(
|
||||
"What promo code can I use for free overnight shipping? Cite the source.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("SHIP-CANARY-4493", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RagAnswer_StaysGroundedWithoutContext_WhenAskedUnrelatedQuestionAsync()
|
||||
{
|
||||
// Arrange: ask something that is NOT covered by the three seeded Contoso documents.
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync(
|
||||
"What is the boiling point of liquid nitrogen in degrees Celsius? " +
|
||||
"Just give the number with units, no other context.");
|
||||
|
||||
// Assert: response is non empty AND does NOT fabricate a Contoso source citation.
|
||||
// The agent may either answer from its general knowledge or admit uncertainty; either
|
||||
// is acceptable. The key assertion is that we do not see a fake Contoso link.
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.DoesNotContain("contoso.com", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=azure-search-rag</c> mode.
|
||||
/// Wires the container up with an Azure AI Search backed <see cref="Microsoft.Agents.AI.TextSearchProvider"/>
|
||||
/// adapter that retrieves Contoso Outdoors documents from a pre-provisioned search index before each
|
||||
/// model invocation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Prerequisites managed out of band:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The <c>it-azure-search-rag</c> agent's managed identity must hold
|
||||
/// <c>Search Index Data Reader</c> on the search service scope. Granted manually after
|
||||
/// the first <c>scripts/it-bootstrap-agents.ps1</c> run; see the IT README.</description></item>
|
||||
/// <item><description>The search index referenced by <c>AZURE_SEARCH_INDEX_NAME</c> must
|
||||
/// already exist with the documented schema and Contoso Outdoors content. The search
|
||||
/// service is shared with <c>python-sample-validation.yml</c>; no .NET-side provisioning
|
||||
/// script ships with this repository.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class AzureSearchRagHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "azure-search-rag";
|
||||
|
||||
/// <summary>
|
||||
/// Inject the AZURE_SEARCH_* env vars onto the hosted agent definition so the test container
|
||||
/// scenario branch can construct its <c>SearchClient</c>. These names are NOT in the platform
|
||||
/// reserved <c>FOUNDRY_*</c> / <c>AGENT_*</c> namespace so they are safe to set.
|
||||
/// </summary>
|
||||
protected override void ConfigureEnvironment(IDictionary<string, string> environment)
|
||||
{
|
||||
environment[TestSettings.AzureSearchEndpoint] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchEndpoint);
|
||||
environment[TestSettings.AzureSearchIndexName] = TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=session-files</c> mode.
|
||||
/// The container exposes three local function tools (<c>GetHomeDirectory</c>, <c>ListFiles</c>,
|
||||
/// <c>ReadFile</c>) that read from the per-session <c>$HOME</c> sandbox volume — mirroring the
|
||||
/// <c>Hosted-Files</c> sample. Tests use the alpha
|
||||
/// <see cref="Azure.AI.Projects.Agents.AgentSessionFiles"/> API to upload a file into the session
|
||||
/// sandbox, then invoke the agent (pinned to the same <c>agent_session_id</c>) and assert that the
|
||||
/// agent's tools observed the uploaded file.
|
||||
/// </summary>
|
||||
public sealed class SessionFilesHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "session-files";
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a hosted agent that runs the test container in <c>IT_SCENARIO=toolbox</c> mode.
|
||||
/// The container hosts a Foundry toolbox with at least one server registered tool. Tests verify
|
||||
/// that the model can invoke those tools and that client side toolbox additions surface alongside
|
||||
/// server side registrations when listed.
|
||||
/// </summary>
|
||||
public sealed class ToolboxHostedAgentFixture : HostedAgentFixture
|
||||
{
|
||||
protected override string ScenarioName => "toolbox";
|
||||
}
|
||||
@@ -20,8 +20,17 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Search.Documents" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Linked from the Hosted-Files sample so the demo testdata file has a single source of truth. -->
|
||||
<Content Include="..\..\samples\04-hosting\FoundryHostedAgents\responses\Hosted-Files\resources\contoso_q1_2026_report.txt"
|
||||
Link="TestData\contoso_q1_2026_report.txt"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -38,6 +38,8 @@ etc.).
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Foundry project | Where to provision the agent. Must be in a region that has the Hosted Agents preview enabled (e.g. East US 2). |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
|
||||
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
|
||||
| `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. |
|
||||
| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. |
|
||||
|
||||
## One-time bootstrap (per Foundry project)
|
||||
|
||||
@@ -57,6 +59,58 @@ The script is idempotent. It requires Owner or User Access Administrator on the
|
||||
scope (RBAC writes). Wait ~3 minutes after first-time grants for AAD propagation before
|
||||
running the tests.
|
||||
|
||||
### Per-scenario data-plane RBAC (manual, one time per agent)
|
||||
|
||||
The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what
|
||||
every hosted agent needs to receive inbound inference traffic. Scenarios that read from
|
||||
external data services need an additional grant on that service to the agent's managed
|
||||
identity. Today only the `azure-search-rag` scenario falls into this category.
|
||||
|
||||
For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader`
|
||||
on the Azure AI Search service to the agent's managed identity:
|
||||
|
||||
```powershell
|
||||
# 1. Get the agent MI principal id
|
||||
$tok = az account get-access-token --resource "https://ai.azure.com" --query accessToken -o tsv
|
||||
$agent = Invoke-RestMethod `
|
||||
-Headers @{Authorization="Bearer $tok"; "Foundry-Features"="HostedAgents=V1Preview"} `
|
||||
-Uri "<project-endpoint>/agents/it-azure-search-rag?api-version=v1"
|
||||
$mi = $agent.versions.latest.instance_identity.principal_id
|
||||
|
||||
# 2. Grant Search Index Data Reader on the search service
|
||||
az role assignment create `
|
||||
--assignee-object-id $mi `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role "Search Index Data Reader" `
|
||||
--scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Search/searchServices/<search-service>"
|
||||
```
|
||||
|
||||
Wait ~3 minutes after the grant for RBAC propagation before running the tests.
|
||||
|
||||
If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra
|
||||
auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first:
|
||||
|
||||
```powershell
|
||||
az search service update -g <rg> -n <search-service> --auth-options aadOrApiKey --aad-auth-failure-mode http403
|
||||
```
|
||||
|
||||
### Azure AI Search index prerequisite (one time, out of band)
|
||||
|
||||
The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already
|
||||
exists with the schema and Contoso Outdoors content the test asserts against. See
|
||||
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for
|
||||
the schema and copy-pasteable provisioning snippet. Provisioning the index from your user
|
||||
identity needs `Search Index Data Contributor` on the search service scope. The search service
|
||||
itself is treated as pre-existing infrastructure shared with `python-sample-validation.yml`;
|
||||
no automated provisioning script ships in this repository.
|
||||
|
||||
### Required user/SP roles for delegating data-plane grants
|
||||
|
||||
To self-serve the `Search Index Data Reader` grant above, you need `User Access Administrator`
|
||||
(or `Owner`) on the search service scope. To create/seed the index from your own identity, you
|
||||
need `Search Index Data Contributor`. These are typically granted once per onboarded engineer
|
||||
and reused for every new IT scenario that needs Search.
|
||||
|
||||
## Building and pushing the test container image
|
||||
|
||||
The test container source lives at `dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer`.
|
||||
@@ -115,6 +169,8 @@ container, the test fixture, or their tooling changed:
|
||||
| `IT_HOSTED_AGENT_PROJECT_ENDPOINT` | `AZURE_AI_PROJECT_ENDPOINT` |
|
||||
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
|
||||
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
|
||||
| `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) |
|
||||
| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) |
|
||||
|
||||
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
|
||||
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
|
||||
@@ -125,6 +181,10 @@ The CI service principal that backs `secrets.AZURE_CLIENT_ID` needs:
|
||||
- `Azure AI User` on the hosted-agents Foundry project (to add/delete agent versions).
|
||||
- `AcrPush` on the registry referenced by `IT_HOSTED_AGENT_REGISTRY` (to push the image).
|
||||
|
||||
The Azure AI Search index referenced by `secrets.AZURE_SEARCH_ENDPOINT` and
|
||||
`secrets.AZURE_SEARCH_INDEX_NAME` is provisioned out of band (shared with
|
||||
`python-sample-validation.yml`); CI does not need write access to the search service.
|
||||
|
||||
The bootstrap script (and one-time `AcrPull` grants for the Foundry project's MIs) is a
|
||||
human-only operation; CI only adds and deletes versions under existing agents.
|
||||
|
||||
@@ -135,9 +195,10 @@ human-only operation; CI only adds and deletes versions under existing agents.
|
||||
| `HappyPathHostedAgentFixture` | `happy-path` | `it-happy-path` | Round trip, streaming, multi turn (`previous_response_id` and `conversation_id`), `stored=false` flag in three combinations, instructions obeyed. |
|
||||
| `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. |
|
||||
| `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. |
|
||||
| `ToolboxHostedAgentFixture` | `toolbox` | `it-toolbox` | Server registered toolbox tool callable; client side additions visible (placeholder). |
|
||||
| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). |
|
||||
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
|
||||
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
|
||||
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
|
||||
|
||||
The placeholder scenarios will be wired up in the test container `Program.cs` once the
|
||||
relevant `Microsoft.Agents.AI.Foundry.Hosting` API surfaces stabilize.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable AAIP001 // AgentSessionFiles is experimental
|
||||
#pragma warning disable OPENAI001 // CreateResponseOptions is experimental
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Responses;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end integration test for the Hosted-Files style scenario: a file uploaded by the client
|
||||
/// via the alpha <see cref="AgentSessionFiles"/> SDK is read by the deployed hosted agent's
|
||||
/// container-side <c>ReadFile</c> tool and surfaces in <see cref="AIAgent.RunAsync(string, AgentSession, AgentRunOptions, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Routing both invocations to the same per-session container requires two clients on the same
|
||||
/// agent-scoped <see cref="ProjectOpenAIClient"/>: a <see cref="ProjectConversationsClient"/> to
|
||||
/// pre-create a conversation bound to the agent endpoint, and a <see cref="ProjectResponsesClient"/>
|
||||
/// for invocation. The session id resolved by the platform on the first call is captured from the
|
||||
/// <c>x-agent-session-id</c> response header and used to target the
|
||||
/// <see cref="AgentSessionFiles"/> upload at the same session's <c>$HOME</c>. The second call
|
||||
/// carries the same conversation_id so it lands in the same container and the agent's
|
||||
/// <c>ReadFile</c> tool sees the upload.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class SessionFilesHostedAgentTests(SessionFilesHostedAgentFixture fixture) : IClassFixture<SessionFilesHostedAgentFixture>
|
||||
{
|
||||
private const string FoundryFeaturesHeader = "Foundry-Features";
|
||||
private const string HostedAgentsFeatureValue = "HostedAgents=V1Preview,AgentEndpoints=V1Preview";
|
||||
private const string SessionIdHeader = "x-agent-session-id";
|
||||
|
||||
private const string TestDataFileName = "contoso_q1_2026_report.txt";
|
||||
|
||||
/// <summary>Token that appears verbatim in the test data file. Proof the agent read what we uploaded.</summary>
|
||||
private const string ExpectedTokenInFile = "1,482.6";
|
||||
|
||||
private readonly SessionFilesHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public async Task UploadedFile_IsReadByHostedAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
string localPath = Path.Combine(AppContext.BaseDirectory, "TestData", TestDataFileName);
|
||||
Assert.True(
|
||||
File.Exists(localPath),
|
||||
$"Test data file not found at '{localPath}'. Confirm the linked Content entry in the csproj.");
|
||||
|
||||
var endpoint = new Uri(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint));
|
||||
var credential = TestAzureCliCredentials.CreateAzureCliCredential();
|
||||
|
||||
// Admin client + AgentSessionFiles for upload/list/delete (alpha SDK).
|
||||
var adminOptions = new AgentAdministrationClientOptions();
|
||||
adminOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
|
||||
var adminClient = new AgentAdministrationClient(endpoint, credential, adminOptions);
|
||||
var sessionFiles = adminClient.GetAgentSessionFiles();
|
||||
|
||||
// Build the per-agent OpenAI client. The conversation is created on this client so it is
|
||||
// bound to the agent endpoint URL (`/agents/{name}/endpoint/protocols/openai/conversations`).
|
||||
// A header-capture policy reads the `x-agent-session-id` the platform stamps on every reply.
|
||||
var headerCapture = new ResponseHeaderCapturePolicy(SessionIdHeader);
|
||||
var openAIOptions = new ProjectOpenAIClientOptions { AgentName = this._fixture.AgentName };
|
||||
openAIOptions.AddPolicy(new FoundryFeaturesPolicy(HostedAgentsFeatureValue), PipelinePosition.PerCall);
|
||||
openAIOptions.AddPolicy(headerCapture, PipelinePosition.PerCall);
|
||||
var openAIClient = new ProjectOpenAIClient(endpoint, credential, openAIOptions);
|
||||
var conversations = openAIClient.GetProjectConversationsClient();
|
||||
var responses = openAIClient.GetProjectResponsesClient();
|
||||
|
||||
// Step 1 — create a conversation bound to the agent endpoint. Subsequent /responses calls
|
||||
// tagged with this conversation_id route to the same per-session container.
|
||||
var conversation = await conversations.CreateProjectConversationAsync();
|
||||
string conversationId = conversation.Value.Id;
|
||||
|
||||
try
|
||||
{
|
||||
// Step 2 — warm-up call. Provisions the per-session container under the conversation and
|
||||
// lets us read back the resolved agent_session_id from the response header.
|
||||
var agent = responses.AsIChatClient().AsAIAgent(name: this._fixture.AgentName);
|
||||
var convOptions = new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId });
|
||||
|
||||
var warmup = await agent.RunAsync(
|
||||
"Reply with the single word 'ready' and nothing else.",
|
||||
options: convOptions);
|
||||
Assert.False(string.IsNullOrWhiteSpace(warmup.Text));
|
||||
|
||||
string agentSessionId = headerCapture.LastValue
|
||||
?? throw new InvalidOperationException(
|
||||
$"Expected '{SessionIdHeader}' response header on warm-up but got none.");
|
||||
|
||||
try
|
||||
{
|
||||
// Step 3 — upload the file via the alpha AgentSessionFiles SDK to that exact session's $HOME.
|
||||
SessionFileWriteResponse writeResponse = await sessionFiles.UploadSessionFileAsync(
|
||||
agentName: this._fixture.AgentName,
|
||||
sessionId: agentSessionId,
|
||||
sessionStoragePath: TestDataFileName,
|
||||
localPath: localPath);
|
||||
|
||||
long expectedBytes = new FileInfo(localPath).Length;
|
||||
Assert.Equal(expectedBytes, writeResponse.BytesWritten);
|
||||
|
||||
SessionDirectoryListResponse listing = await sessionFiles.GetSessionFilesAsync(
|
||||
agentName: this._fixture.AgentName,
|
||||
sessionId: agentSessionId,
|
||||
sessionStoragePath: ".");
|
||||
Assert.Contains(
|
||||
listing.Entries,
|
||||
e => e.Name == TestDataFileName && !e.IsDirectory && e.Size == expectedBytes);
|
||||
|
||||
// Step 4 — invoke the agent again on the SAME conversation. The platform routes back to
|
||||
// the same agent_session_id container, so the agent's ReadFile tool sees the upload.
|
||||
// The platform mutates session/conversation revision when AgentSessionFiles uploads land,
|
||||
// so an immediate /responses follow-up races and 400's with "modified concurrently. Please
|
||||
// retry." — the response message literally tells us to retry. Bounded retry handles it.
|
||||
var readOptions = new CreateResponseOptions { AgentConversationId = conversationId };
|
||||
readOptions.InputItems.Add(ResponseItem.CreateUserMessageItem(
|
||||
$"Read {TestDataFileName} from $HOME and quote the headline total revenue figure verbatim, no commentary."));
|
||||
|
||||
ClientResult<ResponseResult> rawResponse = null!;
|
||||
const int MaxAttempts = 5;
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
rawResponse = await responses.CreateResponseAsync(readOptions);
|
||||
break;
|
||||
}
|
||||
catch (ClientResultException ex) when (
|
||||
ex.Status == 400 &&
|
||||
ex.Message.Contains("modified concurrently", StringComparison.OrdinalIgnoreCase) &&
|
||||
attempt < MaxAttempts)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2 * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
string responseText = rawResponse.Value.GetOutputText() ?? string.Empty;
|
||||
|
||||
Assert.Equal(agentSessionId, headerCapture.LastValue);
|
||||
|
||||
// Assert: the response contains the deterministic token from the file.
|
||||
Assert.False(string.IsNullOrWhiteSpace(responseText));
|
||||
Assert.Contains(ExpectedTokenInFile, responseText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort cleanup of the uploaded file. The session itself is left for TTL expiry —
|
||||
// the platform owns its lifecycle (no isolation key in our hands).
|
||||
try
|
||||
{
|
||||
await sessionFiles.DeleteSessionFileAsync(
|
||||
agentName: this._fixture.AgentName,
|
||||
sessionId: agentSessionId,
|
||||
path: TestDataFileName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this._fixture.DeleteConversationAsync(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures a response header value on every pipeline call. Latest value is read after the
|
||||
/// response completes. Used to grab the platform's <c>x-agent-session-id</c> stamp.
|
||||
/// </summary>
|
||||
private sealed class ResponseHeaderCapturePolicy(string headerName) : PipelinePolicy
|
||||
{
|
||||
private readonly string _headerName = headerName;
|
||||
private string? _lastValue;
|
||||
|
||||
public string? LastValue => Volatile.Read(ref this._lastValue);
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
this.Capture(message);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
this.Capture(message);
|
||||
}
|
||||
|
||||
private void Capture(PipelineMessage message)
|
||||
{
|
||||
if (message.Response is not null &&
|
||||
message.Response.Headers.TryGetValue(this._headerName, out var value) &&
|
||||
!string.IsNullOrEmpty(value))
|
||||
{
|
||||
Volatile.Write(ref this._lastValue, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FoundryFeaturesPolicy(string features) : PipelinePolicy
|
||||
{
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
this.SetHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void SetHeader(PipelineMessage message)
|
||||
{
|
||||
message.Request.Headers.Remove(FoundryFeaturesHeader);
|
||||
message.Request.Headers.Add(FoundryFeaturesHeader, features);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Foundry.Hosting.IntegrationTests.Fixtures;
|
||||
|
||||
namespace Foundry.Hosting.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Foundry toolbox: the hosted container registers tools via the toolbox API
|
||||
/// (server side), and tests can also add tools client side. The model should be able to
|
||||
/// invoke tools from both sources.
|
||||
/// </summary>
|
||||
[Trait("Category", "FoundryHostedAgents")]
|
||||
public sealed class ToolboxHostedAgentTests(ToolboxHostedAgentFixture fixture) : IClassFixture<ToolboxHostedAgentFixture>
|
||||
{
|
||||
private readonly ToolboxHostedAgentFixture _fixture = fixture;
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ServerRegisteredToolboxTool_IsCallableAsync()
|
||||
{
|
||||
// Arrange: the container side toolbox registers GetEnvironmentName which returns a constant.
|
||||
var agent = this._fixture.Agent;
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync("Call GetEnvironmentName via the toolbox and reply with just the value.");
|
||||
|
||||
// Assert
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
Assert.Contains("integration-test", response.Text, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ClientSideAddedToolboxTool_IsListedAndCallableAsync()
|
||||
{
|
||||
// TODO: requires AgentToolboxes API surface. Placeholder asserting the test runs.
|
||||
var agent = this._fixture.Agent;
|
||||
var response = await agent.RunAsync("List all tools you have access to.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")]
|
||||
public async Task ListingTools_ReturnsBothServerAndClientSideEntriesAsync()
|
||||
{
|
||||
// TODO: requires AgentAdministrationClient toolbox listing. Placeholder.
|
||||
var agent = this._fixture.Agent;
|
||||
var response = await agent.RunAsync("Briefly describe what tools are available.");
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.Text));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@
|
||||
Container image reference for the placeholder version (e.g. <acr>.azurecr.io/foundry-hosting-it:<tag>).
|
||||
Use the value emitted by scripts/it-build-image.ps1.
|
||||
|
||||
.NOTES
|
||||
Per-scenario data-plane RBAC (e.g. `Search Index Data Reader` on the Azure AI Search service
|
||||
for the `azure-search-rag` scenario) is intentionally NOT performed by this script. Search,
|
||||
Cosmos, and other backing services are treated as pre-existing infrastructure. Grant the
|
||||
scenario-specific data role to the agent's managed identity manually after the first run
|
||||
(see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md).
|
||||
|
||||
.EXAMPLE
|
||||
./it-bootstrap-agents.ps1 `
|
||||
-ProjectEndpoint "https://my-acct.services.ai.azure.com/api/projects/my-proj" `
|
||||
@@ -36,9 +43,10 @@ $Scenarios = @(
|
||||
'happy-path',
|
||||
'tool-calling',
|
||||
'tool-calling-approval',
|
||||
'toolbox',
|
||||
'mcp-toolbox',
|
||||
'custom-storage'
|
||||
'custom-storage',
|
||||
'azure-search-rag',
|
||||
'session-files'
|
||||
)
|
||||
|
||||
# Resolve project ARM scope from the endpoint.
|
||||
|
||||
@@ -41,14 +41,7 @@ param(
|
||||
|
||||
[string] $Repository = "foundry-hosting-it",
|
||||
|
||||
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer",
|
||||
|
||||
# Explicit opt-in for the no-rebuild fast path. CI sets this after running the
|
||||
# "Build Foundry hosted IT (and its deps)" step, which guarantees the prebuilt
|
||||
# library DLLs match current source. Off by default so local invocations always
|
||||
# let publish rebuild ProjectReferences and never produce an image whose tag is
|
||||
# computed from current source while the contents come from a stale build.
|
||||
[switch] $UsePrebuiltProjectReferences
|
||||
[string] $TestContainerProject = "dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -107,60 +100,35 @@ if (Test-Path $out) {
|
||||
Remove-Item -Recurse -Force $out
|
||||
}
|
||||
|
||||
# Conditionally tell publish to skip rebuilding ProjectReferences and consume the
|
||||
# prebuilt library DLLs in place. This avoids two failure modes that arise when
|
||||
# the CI workflow runs a `dotnet build` of the same library projects immediately
|
||||
# before this script:
|
||||
# 1) MSB3026 "file is being used by another process" when publish's MSBuild
|
||||
# tries to overwrite src/<lib>/bin/Release/net10.0/<lib>.dll while the
|
||||
# previous build's shared-compilation server still holds a file handle.
|
||||
# 2) Publish needlessly rebuilding identical managed (RID-agnostic) library
|
||||
# DLLs that prebuild already produced.
|
||||
# Gated on -UsePrebuiltProjectReferences (a strict opt-in) instead of marker
|
||||
# detection, because a developer machine may have a stale Release build of the
|
||||
# libraries from days ago; using those would silently produce an image whose
|
||||
# content is older than the source the tag is computed from.
|
||||
$publishExtraArgs = @()
|
||||
if ($UsePrebuiltProjectReferences) {
|
||||
Write-Host "-UsePrebuiltProjectReferences: skipping ProjectReference rebuild." -ForegroundColor DarkGray
|
||||
$publishExtraArgs += "-p:BuildProjectReferences=false"
|
||||
} else {
|
||||
# Preflight: in default (rebuild) mode, publish propagates RuntimeIdentifier=linux-musl-x64
|
||||
# to library ProjectReferences and writes their intermediates to a RID-suffixed obj path
|
||||
# (e.g. obj/Release/net10.0/linux-musl-x64/). DefaultItemExcludes follows the new
|
||||
# IntermediateOutputPath, so any *.AssemblyInfo.cs left in obj/Release/net10.0/ from a
|
||||
# prior `dotnet build` is no longer excluded and gets picked up by the **/*.cs Compile
|
||||
# glob, producing CS0579 "duplicate attribute" errors. Detect that state up front and
|
||||
# tell the user exactly how to recover.
|
||||
$staleObjProbes = @(
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/obj/Release/net10.0",
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry/obj/Release/net10.0",
|
||||
"dotnet/src/Microsoft.Agents.AI/obj/Release/net10.0",
|
||||
"dotnet/src/Microsoft.Agents.AI.Abstractions/obj/Release/net10.0"
|
||||
)
|
||||
$stale = @($staleObjProbes | Where-Object { Test-Path (Join-Path $_ "*.AssemblyInfo.cs") })
|
||||
if ($stale.Count -gt 0) {
|
||||
$msg = @(
|
||||
"Detected prior Release/net10.0 build outputs in:"
|
||||
($stale | ForEach-Object { " - $_" })
|
||||
""
|
||||
"Publish would propagate -r linux-musl-x64 to those ProjectReferences and the"
|
||||
"leftover obj/Release/net10.0/*.AssemblyInfo.cs files would cause CS0579 duplicate"
|
||||
"attribute errors. Pick one:"
|
||||
" (a) Pass -UsePrebuiltProjectReferences (skips ProjectReference rebuild and"
|
||||
" uses the existing src/<lib>/bin/Release/net10.0/*.dll outputs in place)."
|
||||
" Only safe when you know those DLLs match current source - this is the path"
|
||||
" CI uses immediately after its 'Build Foundry hosted IT (and its deps)' step."
|
||||
" (b) Remove the stale obj/Release trees, e.g.:"
|
||||
" Remove-Item -Recurse -Force dotnet/src/Microsoft.Agents.AI*/obj/Release"
|
||||
" and re-run."
|
||||
) -join "`n"
|
||||
throw $msg
|
||||
}
|
||||
Write-Host "Letting publish build ProjectReferences (pass -UsePrebuiltProjectReferences in CI to skip)." -ForegroundColor DarkGray
|
||||
# Always tell publish to skip ProjectReference rebuilds via --no-dependencies. Publish
|
||||
# resolves TestContainer's framework lib references (Foundry, Foundry.Hosting and their
|
||||
# transitive deps) by reading the prebuilt DLLs at src/<lib>/bin/Release/net10.0/*.dll.
|
||||
# This:
|
||||
# 1) Structurally avoids the MSB3026 "file is being used by another process" race that
|
||||
# occurs when publish overwrites the same DLL paths a prior `dotnet build` produced
|
||||
# while VBCSCompiler from that build still holds file handles.
|
||||
# 2) Avoids needlessly rebuilding identical managed (RID-agnostic) library DLLs.
|
||||
# Callers MUST run `dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release`
|
||||
# (or equivalent) first so those prebuilt DLLs exist. The CI workflow does this in the
|
||||
# preceding "Build Foundry hosted IT (and its deps)" step.
|
||||
$prebuildProbes = @(
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.dll",
|
||||
"dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/bin/Release/net10.0/Microsoft.Agents.AI.Foundry.Hosting.dll"
|
||||
)
|
||||
$missingPrebuilds = @($prebuildProbes | Where-Object { -not (Test-Path $_) })
|
||||
if ($missingPrebuilds.Count -gt 0) {
|
||||
$msg = @(
|
||||
"Required prebuilt outputs not found:"
|
||||
($missingPrebuilds | ForEach-Object { " - $_" })
|
||||
""
|
||||
"Publish runs with --no-dependencies and consumes prebuilt DLLs in place. Build the"
|
||||
"test project first so its ProjectReference closure populates src/<lib>/bin/Release/net10.0/:"
|
||||
" dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c Release"
|
||||
) -join "`n"
|
||||
throw $msg
|
||||
}
|
||||
|
||||
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false -o $out @publishExtraArgs --tl:off | Out-Host
|
||||
dotnet publish $TestContainerProject -c Release -f net10.0 -r linux-musl-x64 --self-contained false --no-dependencies -o $out --tl:off | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.DevUI.UnitTests;
|
||||
|
||||
public class DevUIAccessControlTests
|
||||
{
|
||||
private static WebApplicationBuilder NewBuilder()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("agent-name", agent);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void SimulateRemoteIp(WebApplication app, IPAddress remoteIp)
|
||||
{
|
||||
app.Use(async (HttpContext ctx, RequestDelegate next) =>
|
||||
{
|
||||
ctx.Connection.RemoteIpAddress = remoteIp;
|
||||
await next(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonLoopbackRequest_ReturnsForbiddenByDefaultAsync()
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
using var app = builder.Build();
|
||||
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonLoopbackRequest_IsAllowedWhenAllowRemoteAccessAsync()
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using var app = builder.Build();
|
||||
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoopbackRequest_WithAuthTokenSet_RequiresBearerHeaderAsync()
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
||||
|
||||
using var app = builder.Build();
|
||||
SimulateRemoteIp(app, IPAddress.Loopback);
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync()
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
||||
|
||||
using var app = builder.Build();
|
||||
SimulateRemoteIp(app, IPAddress.Loopback);
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token");
|
||||
var response = await app.GetTestClient().SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredAsync()
|
||||
{
|
||||
const string EnvVar = "DEVUI_AUTH_TOKEN";
|
||||
const string EnvToken = "env-token";
|
||||
var previous = Environment.GetEnvironmentVariable(EnvVar);
|
||||
Environment.SetEnvironmentVariable(EnvVar, EnvToken);
|
||||
|
||||
WebApplication? app = null;
|
||||
try
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI();
|
||||
|
||||
app = builder.Build();
|
||||
|
||||
// Force singleton construction so the env var is captured before we
|
||||
// restore it; otherwise tests running in parallel can pick up the
|
||||
// leaked DEVUI_AUTH_TOKEN.
|
||||
_ = app.Services.GetRequiredService<DevUIAuthFilter>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, previous);
|
||||
}
|
||||
|
||||
await using (app)
|
||||
{
|
||||
SimulateRemoteIp(app, IPAddress.Loopback);
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
var missing = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", EnvToken);
|
||||
var accepted = await app.GetTestClient().SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MetaEndpoint_IsReachableWithoutAuthenticationAsync()
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
||||
|
||||
using var app = builder.Build();
|
||||
SimulateRemoteIp(app, IPAddress.Loopback);
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
var response = await app.GetTestClient().GetAsync(new Uri("/meta", UriKind.Relative));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"auth_required\":true", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync()
|
||||
{
|
||||
var builder = NewBuilder();
|
||||
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
||||
|
||||
using var app = builder.Build();
|
||||
SimulateRemoteIp(app, IPAddress.Loopback);
|
||||
app.MapDevUI();
|
||||
await app.StartAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-the-token");
|
||||
var response = await app.GetTestClient().SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class DevUIIntegrationTests
|
||||
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
|
||||
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("registration-key", agent);
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
@@ -66,7 +66,7 @@ public class DevUIIntegrationTests
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agent1);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agent2);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-3", agent3);
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
@@ -102,7 +102,7 @@ public class DevUIIntegrationTests
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agentKeyed1);
|
||||
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agentKeyed2);
|
||||
builder.Services.AddSingleton<AIAgent>(agentDefault);
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
@@ -151,7 +151,7 @@ public class DevUIIntegrationTests
|
||||
builder.Services.AddKeyedSingleton("key-1", workflow1);
|
||||
builder.Services.AddKeyedSingleton("key-2", workflow2);
|
||||
builder.Services.AddKeyedSingleton("key-3", workflow3);
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
@@ -197,7 +197,7 @@ public class DevUIIntegrationTests
|
||||
builder.Services.AddKeyedSingleton("key-1", workflowKeyed1);
|
||||
builder.Services.AddKeyedSingleton("key-2", workflowKeyed2);
|
||||
builder.Services.AddSingleton(workflowDefault);
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
@@ -255,7 +255,7 @@ public class DevUIIntegrationTests
|
||||
builder.Services.AddKeyedSingleton("workflow-key-1", workflow1);
|
||||
builder.Services.AddKeyedSingleton("workflow-key-2", workflow2);
|
||||
builder.Services.AddSingleton(workflowDefault);
|
||||
builder.Services.AddDevUI();
|
||||
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
app.MapDevUI();
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
#pragma warning disable OPENAI001
|
||||
#pragma warning disable AAIP001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FoundryToolbox"/> class.
|
||||
/// </summary>
|
||||
public class FoundryToolboxTests
|
||||
{
|
||||
private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project");
|
||||
|
||||
#region Parameter validation tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: null!,
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
projectEndpoint: s_testEndpoint,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: name!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_NullEndpoint_ThrowsAsync()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
FoundryToolbox.GetToolsAsync(
|
||||
projectEndpoint: null!,
|
||||
credential: new FakeAuthenticationTokenProvider(),
|
||||
name: "test-toolbox"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullToolboxVersion_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
FoundryToolbox.ToAITools(null!));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToAITools conversion tests
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_EmptyTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "empty-toolbox",
|
||||
version: "v1",
|
||||
description: "Empty",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: Array.Empty<ProjectsAgentTool>(),
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_NullTools_ReturnsEmptyList()
|
||||
{
|
||||
var version = ProjectsAgentsModelFactory.ToolboxVersion(
|
||||
metadata: null,
|
||||
id: "ver-1",
|
||||
name: "null-tools-toolbox",
|
||||
version: "v1",
|
||||
description: "Null tools",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
tools: null,
|
||||
policies: null);
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Empty(tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_WithCodeInterpreterTool_ReturnsAITool()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools()
|
||||
{
|
||||
var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson();
|
||||
var version = ModelReaderWriter.Read<ToolboxVersion>(BinaryData.FromString(json))!;
|
||||
|
||||
var tools = version.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
Assert.IsAssignableFrom<AITool>(aiTool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields()
|
||||
{
|
||||
const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}";
|
||||
var tool = ModelReaderWriter.Read<ProjectsAgentTool>(BinaryData.FromString(ToolJson))!;
|
||||
|
||||
var aiTool = FoundryToolbox.SanitizeAndConvert(tool);
|
||||
|
||||
Assert.NotNull(aiTool);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration tests with mock HTTP
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
Assert.Single(result.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync()
|
||||
{
|
||||
var recordJson = TestDataUtil.GetToolboxRecordResponseJson();
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
var callCount = 0;
|
||||
|
||||
using var httpHandler = new HttpHandlerAssert((request) =>
|
||||
{
|
||||
callCount++;
|
||||
var path = request.RequestUri!.PathAndQuery;
|
||||
|
||||
if (!path.Contains("/versions/"))
|
||||
{
|
||||
Assert.Contains("/toolboxes/research_tools", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(recordJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
Assert.Contains("/toolboxes/research_tools/versions/v5", path);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: null,
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("research_tools", result.Name);
|
||||
Assert.Equal("v5", result.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync()
|
||||
{
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
await Assert.ThrowsAsync<ClientResultException>(() =>
|
||||
FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"nonexistent-toolbox",
|
||||
version: "v1",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) };
|
||||
|
||||
var result = await FoundryToolbox.GetToolboxVersionAsync(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
"research_tools",
|
||||
version: "v5",
|
||||
clientOptions: clientOptions,
|
||||
cancellationToken: default);
|
||||
|
||||
var tools = result.ToAITools();
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIProjectClient extension tests
|
||||
|
||||
[Fact]
|
||||
public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync()
|
||||
{
|
||||
var versionJson = TestDataUtil.GetToolboxVersionResponseJson();
|
||||
using var httpHandler = new HttpHandlerAssert((_) =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(versionJson, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(httpHandler);
|
||||
#pragma warning restore CA5399
|
||||
var clientOptions = new AIProjectClientOptions();
|
||||
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
|
||||
var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions);
|
||||
|
||||
var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5");
|
||||
|
||||
Assert.Single(tools);
|
||||
Assert.IsAssignableFrom<AITool>(tools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// 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
-13
@@ -9,7 +9,7 @@
|
||||
<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="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
@@ -20,16 +20,4 @@
|
||||
<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>
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"id": "tbx-123",
|
||||
"name": "research_tools",
|
||||
"default_version": "v5"
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-research_tools-v5",
|
||||
"name": "research_tools",
|
||||
"version": "v5",
|
||||
"description": "Example research toolbox",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter" }
|
||||
]
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"id": "tbv-dirty-v1",
|
||||
"name": "dirty_toolbox",
|
||||
"version": "v1",
|
||||
"description": "Toolbox with decoration fields on tools",
|
||||
"created_at": 1775779200,
|
||||
"tools": [
|
||||
{ "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" }
|
||||
]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
+18
-11
@@ -245,29 +245,33 @@ public sealed class ClientHeadersExtensionsTests
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// 10. ClientHeadersScope.Push is LIFO and AsyncLocal-isolated (parallel runs don't leak)
|
||||
// 10. ClientHeadersScope is AsyncLocal-isolated across parallel runs and auto-restores on
|
||||
// async-method return (no explicit Dispose needed).
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ClientHeadersScope_IsLifoAndAsyncLocalIsolatedAsync()
|
||||
public async Task ClientHeadersScope_IsAsyncLocalIsolatedAndAutoRestoresAsync()
|
||||
{
|
||||
// Arrange
|
||||
var dictA = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
|
||||
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
|
||||
|
||||
// Act / Assert
|
||||
// Act / Assert: parallel async flows do not see each other's mutations.
|
||||
await Task.WhenAll(
|
||||
ProbeAsync(dictA, "alice"),
|
||||
ProbeAsync(dictB, "bob"));
|
||||
|
||||
async Task ProbeAsync(Dictionary<string, string> dict, string expected)
|
||||
{
|
||||
using (ClientHeadersScope.Push(dict))
|
||||
{
|
||||
await Task.Yield();
|
||||
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
|
||||
}
|
||||
ClientHeadersScope.Current = dict;
|
||||
await Task.Yield();
|
||||
Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
|
||||
}
|
||||
|
||||
// Assert: setting Current inside an awaited async method does not leak back to the caller
|
||||
// after the method returns. This is the AsyncLocal natural-restoration behavior the
|
||||
// ClientHeadersAgent relies on.
|
||||
Assert.Null(ClientHeadersScope.Current);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
@@ -320,16 +324,19 @@ public sealed class ClientHeadersExtensionsTests
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
var perCall = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
|
||||
|
||||
// Act
|
||||
using (ClientHeadersScope.Push(perCall))
|
||||
ClientHeadersScope.Current = new Dictionary<string, string> { ["x-client-end-user-id"] = "alice" };
|
||||
try
|
||||
{
|
||||
var msg = pipeline.CreateMessage();
|
||||
msg.Request.Method = "GET";
|
||||
msg.Request.Uri = new Uri("https://example.test/");
|
||||
await pipeline.SendAsync(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClientHeadersScope.Current = null;
|
||||
}
|
||||
|
||||
// Assert: the per-call value won.
|
||||
Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -184,7 +185,7 @@ public class FoundryAgentTests
|
||||
|
||||
// Act: this AsAIAgent path constructs FoundryAgent via its internal
|
||||
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
|
||||
var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
|
||||
@@ -398,4 +399,379 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Agent-endpoint constructor tests
|
||||
|
||||
private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
|
||||
private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint);
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException()
|
||||
{
|
||||
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider()));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException()
|
||||
{
|
||||
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!));
|
||||
Assert.Equal("credential", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Equal("it-happy-path", agent.Name);
|
||||
Assert.Equal("it-happy-path", agent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
|
||||
{
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Test");
|
||||
|
||||
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_AppliesClientFactoryOnce()
|
||||
{
|
||||
int count = 0;
|
||||
FoundryAgent agent = new(
|
||||
s_testAgentEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
clientFactory: c => { count++; return c; });
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync()
|
||||
{
|
||||
Uri? capturedUri = null;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
capturedUri = req.RequestUri;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.NotNull(capturedUri);
|
||||
string path = capturedUri!.AbsolutePath;
|
||||
Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync()
|
||||
{
|
||||
Uri? capturedUri = null;
|
||||
bool sawStreamTrue = false;
|
||||
using HttpHandlerAssert handler = new(async req =>
|
||||
{
|
||||
capturedUri = req.RequestUri;
|
||||
if (req.Content is not null)
|
||||
{
|
||||
string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (body.Contains("\"stream\":true", StringComparison.Ordinal))
|
||||
{
|
||||
sawStreamTrue = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal SSE response; xUnit assertion only cares about the URL/body shape.
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
try
|
||||
{
|
||||
await foreach (var _ in agent.RunStreamingAsync("Hello"))
|
||||
{
|
||||
// drain
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// SSE parse errors are acceptable; we only assert the request shape.
|
||||
}
|
||||
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync()
|
||||
{
|
||||
Uri? capturedUri = null;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
capturedUri = req.RequestUri;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"conv_123\"}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
try
|
||||
{
|
||||
_ = await agent.CreateConversationSessionAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing.
|
||||
}
|
||||
|
||||
Assert.NotNull(capturedUri);
|
||||
string path = capturedUri!.AbsolutePath;
|
||||
Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync()
|
||||
{
|
||||
bool meaiSeen = false;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
if (req.Headers.TryGetValues("User-Agent", out var values))
|
||||
{
|
||||
foreach (string v in values)
|
||||
{
|
||||
if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
meaiSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
|
||||
{
|
||||
// Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies
|
||||
// (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a
|
||||
// tag-stamping policy executes on each outbound per-agent request.
|
||||
bool tagSeen = false;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
if (req.Headers.TryGetValues("X-Test-Tag", out var values))
|
||||
{
|
||||
foreach (string v in values)
|
||||
{
|
||||
if (v == "tag-1")
|
||||
{
|
||||
tagSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall);
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName()
|
||||
{
|
||||
// The caller may set Endpoint/AgentName on the options bag; we must override both with
|
||||
// values derived from agentEndpoint so the URL routing is correct regardless.
|
||||
ProjectOpenAIClientOptions opts = new()
|
||||
{
|
||||
Endpoint = new Uri("https://wrong.example.com/openai/v1"),
|
||||
AgentName = "wrong-agent",
|
||||
};
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
|
||||
Assert.Equal("it-happy-path", agent.Name);
|
||||
Assert.Equal(s_testAgentEndpoint, opts.Endpoint);
|
||||
Assert.Equal("it-happy-path", opts.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
|
||||
{
|
||||
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
|
||||
// application-id stamp in the outbound request. Verify the value is propagated onto the
|
||||
// project-level client's options via the public ProjectOpenAIClient surface.
|
||||
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
|
||||
ProjectOpenAIClient? projectClient = agent.GetService<ProjectOpenAIClient>();
|
||||
Assert.NotNull(projectClient);
|
||||
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
|
||||
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint tests
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_StandardShape_Parses()
|
||||
{
|
||||
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai"));
|
||||
Assert.Equal("a1", name);
|
||||
Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_TrailingSlash_Parses()
|
||||
{
|
||||
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/"));
|
||||
Assert.Equal("a1", name);
|
||||
Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses()
|
||||
{
|
||||
var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai"));
|
||||
Assert.Equal("a1", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_SpecialCharsInName_Parses()
|
||||
{
|
||||
var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai"));
|
||||
Assert.Equal("it-happy_path-1", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_QueryAndFragmentStripped()
|
||||
{
|
||||
var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag"));
|
||||
Assert.Equal(string.Empty, root.Query);
|
||||
Assert.Equal(string.Empty, root.Fragment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses()
|
||||
{
|
||||
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai"));
|
||||
Assert.Equal("a1", name);
|
||||
Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_MissingAgentsSegment_Throws()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1")));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_WrongSuffix_Throws()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1")));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_EmptyAgentName_Throws()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai")));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class HeaderStampPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string _value;
|
||||
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
|
||||
|
||||
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="MessageInjectingChatClient"/>.
|
||||
/// </summary>
|
||||
public class MessageInjectingChatClientTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="MessageInjectingChatClient"/> is resolvable via GetService when the decorator is active.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
// Act
|
||||
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(injector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="MessageInjectingChatClient"/> is null when the decorator is not active.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetService_ReturnsNull_WhenDecoratorNotActive()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new());
|
||||
|
||||
// Act
|
||||
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
|
||||
|
||||
// Assert
|
||||
Assert.Null(injector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that messages enqueued on the session before RunAsync are included in the service call messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
capturedMessages.AddRange(msgs))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
// Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
var queue = new List<ChatMessage>();
|
||||
queue.Add(new ChatMessage(ChatRole.User, "injected message"));
|
||||
session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "original")], session);
|
||||
|
||||
// Assert — the service should have received both the original and injected messages
|
||||
Assert.Contains(capturedMessages, m => m.Text == "original");
|
||||
Assert.Contains(capturedMessages, m => m.Text == "injected message");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
capturedMessages.AddRange(msgs))
|
||||
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
// Create session and enqueue a message directly onto the session's StateBag queue
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
var queue = new List<ChatMessage>();
|
||||
queue.Add(new ChatMessage(ChatRole.User, "injected once"));
|
||||
session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([new(ChatRole.User, "first call")], session);
|
||||
|
||||
// Assert — the injected message was included in the service call
|
||||
Assert.Contains(capturedMessages, m => m.Text == "injected once");
|
||||
|
||||
// Assert — the session's queue is now empty (drained)
|
||||
Assert.Empty(queue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the internal loop fires when no actionable FunctionCallContent is returned
|
||||
/// but there are pending injected messages in the queue.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
// First call — simulate that something enqueues a message (e.g., a provider or background task)
|
||||
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]);
|
||||
}
|
||||
|
||||
// Return a plain text response (no FunctionCallContent) to trigger the internal loop
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")]));
|
||||
});
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
sessionRef = session;
|
||||
await agent.RunAsync([new(ChatRole.User, "original")], session);
|
||||
|
||||
// Assert — should have made 2 service calls (internal loop triggered by the injected message)
|
||||
Assert.Equal(2, serviceCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the internal loop does NOT fire when the response contains actionable
|
||||
/// FunctionCallContent, even if there are pending injected messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
// Enqueue a message during the first call
|
||||
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
|
||||
// Return a response with an actionable FunctionCallContent
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
||||
}
|
||||
|
||||
// Subsequent calls return plain text (the FCC loop will call back after tool execution)
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
|
||||
});
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
sessionRef = session;
|
||||
await agent.RunAsync([new(ChatRole.User, "original")], session);
|
||||
|
||||
// Assert — The first service call returned actionable FCC, so no internal injected-message loop
|
||||
// occurred there. The FCC loop invokes the tool and calls the service again (second call).
|
||||
// The injected message should be picked up by the second service call (drained at start of
|
||||
// GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected.
|
||||
Assert.Equal(2, serviceCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the internal loop fires when the response contains only InformationalOnly
|
||||
/// FunctionCallContent (which are not actionable) and there are pending injected messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
// Enqueue a message during the first call
|
||||
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
|
||||
// Return a response with InformationalOnly FCC (not actionable)
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>()) { InformationalOnly = true }])]));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
|
||||
});
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
});
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
sessionRef = session;
|
||||
await agent.RunAsync([new(ChatRole.User, "original")], session);
|
||||
|
||||
// Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger
|
||||
Assert.Equal(2, serviceCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the inner client returns a ConversationId on the first call, the
|
||||
/// MessageInjectingChatClient propagates it to options on subsequent loop iterations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync()
|
||||
{
|
||||
// Arrange
|
||||
int serviceCallCount = 0;
|
||||
List<string?> capturedConversationIds = [];
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> _, ChatOptions? opts, CancellationToken _) =>
|
||||
{
|
||||
serviceCallCount++;
|
||||
capturedConversationIds.Add(opts?.ConversationId);
|
||||
|
||||
if (serviceCallCount == 1)
|
||||
{
|
||||
// First call: inject a message and return a ConversationId
|
||||
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")])
|
||||
{
|
||||
ConversationId = "conv-123",
|
||||
});
|
||||
}
|
||||
|
||||
// Second call (from loop): should have the propagated ConversationId
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")]));
|
||||
});
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
EnableMessageInjection = true,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// Act
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
sessionRef = session;
|
||||
await agent.RunAsync([new(ChatRole.User, "hello")], session);
|
||||
|
||||
// Assert — The second call should have received the ConversationId propagated from the first response
|
||||
Assert.Equal(2, serviceCallCount);
|
||||
Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet
|
||||
Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a session with pending injected messages can be serialized and deserialized,
|
||||
/// and that the deserialized session correctly delivers the injected messages on the next run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessagesFirstRun = [];
|
||||
List<ChatMessage> capturedMessagesSecondRun = [];
|
||||
int runCount = 0;
|
||||
Mock<IChatClient> mockService = new();
|
||||
MessageInjectingChatClient? injectorRef = null;
|
||||
ChatClientAgentSession? sessionRef = null;
|
||||
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
|
||||
{
|
||||
if (runCount == 1)
|
||||
{
|
||||
capturedMessagesFirstRun.AddRange(msgs);
|
||||
|
||||
// Inject a message during the first run — this will remain pending (not drained)
|
||||
// because we return an actionable FCC that causes the parent loop to take over.
|
||||
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]);
|
||||
|
||||
// Return actionable FCC so the injection loop does NOT drain the message
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
|
||||
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
|
||||
}
|
||||
|
||||
// Second run (after deserialization) — capture what messages come through
|
||||
capturedMessagesSecondRun.AddRange(msgs);
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
|
||||
});
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Tools = [tool] },
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
EnableMessageInjection = true,
|
||||
}, services: new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
|
||||
|
||||
// Act — First run: inject a message that stays pending
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
sessionRef = session;
|
||||
runCount = 1;
|
||||
await agent.RunAsync([new(ChatRole.User, "first run message")], session);
|
||||
|
||||
// Serialize the session and deserialize into a new instance
|
||||
var serialized = await agent.SerializeSessionAsync(session!);
|
||||
var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession;
|
||||
|
||||
// Second run on the deserialized session — the injected message should be delivered
|
||||
runCount = 2;
|
||||
sessionRef = deserializedSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession);
|
||||
|
||||
// Assert — the second run should include the injected message from before serialization
|
||||
Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization");
|
||||
Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message");
|
||||
}
|
||||
}
|
||||
+60
@@ -1311,4 +1311,64 @@ public class PerServiceCallChatHistoryPersistingChatClientTests
|
||||
// Assert — session should NOT have the sentinel
|
||||
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the consumer abandons enumeration early (the streaming enumerator is
|
||||
/// disposed before completing — e.g. <c>ToolApprovalAgent.RunStreamingAsync</c> doing a
|
||||
/// <c>yield break</c>), the decorator still persists the input messages via its <c>finally</c>
|
||||
/// block. This regression-guards the dropped-FunctionResultContent → HTTP 400 bug.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_PersistsInputMessages_WhenConsumerAbandonsEnumerationAsync()
|
||||
{
|
||||
// Arrange — emit multiple updates so the consumer can break after the first.
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(CreateAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "first "),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "second "),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "third")));
|
||||
|
||||
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
|
||||
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
|
||||
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||
RequirePerServiceCallChatHistoryPersistence = true,
|
||||
});
|
||||
|
||||
// Act — consumer breaks out after the first update, mirroring ToolApprovalAgent's
|
||||
// yield-break-on-approval-required path.
|
||||
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "frc-input")], session))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Assert — even though the consumer abandoned the stream, the input messages
|
||||
// must still have been persisted (so we don't lose function-call/function-result
|
||||
// pairings).
|
||||
mockChatHistoryProvider
|
||||
.Protected()
|
||||
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
|
||||
ItExpr.Is<ChatHistoryProvider.InvokedContext>(x =>
|
||||
x.RequestMessages.Any(m => m.Text == "frc-input") &&
|
||||
(x.ResponseMessages == null || !x.ResponseMessages.Any()) &&
|
||||
x.InvokeException == null),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,189 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
Func<Task> runStreamingAsync = async () => await executor.HandleAsync(state, testContext);
|
||||
await runStreamingAsync.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_Disabled_DoesNotContinueWithoutHandoff()
|
||||
{
|
||||
// Arrange: agent with 3 prepared turns; autonomous mode OFF
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
TestReplayAgent agent = new(
|
||||
[
|
||||
TestReplayAgent.ToChatMessages("Turn 0 response"),
|
||||
TestReplayAgent.ToChatMessages("Turn 1 response"),
|
||||
TestReplayAgent.ToChatMessages("Turn 2 response"),
|
||||
], TestAgentId, TestAgentName);
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: false);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: without autonomous mode, the agent is called exactly once
|
||||
agent.Turn.Should().Be(1);
|
||||
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
|
||||
.Which.Message.Should().BeOfType<HandoffState>()
|
||||
.Subject;
|
||||
sentState.RequestedHandoffTargetAgentId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_InvokesAgentExactlyOnePlusTurnLimitTimes(int turnLimit)
|
||||
{
|
||||
// Arrange: agent with many prepared turns; no handoff ever requested; autonomous mode ON
|
||||
// We prepare (turnLimit + 2) turns to detect off-by-one errors. TestReplayAgent stops
|
||||
// incrementing Turn when prepared messages are exhausted, so preparing exactly (turnLimit + 1)
|
||||
// turns would fail to detect if the implementation invokes the agent one extra time.
|
||||
int totalTurns = turnLimit + 2;
|
||||
TestReplayAgent agent = new(
|
||||
Enumerable.Range(0, totalTurns)
|
||||
.Select(i => TestReplayAgent.ToChatMessages($"Turn {i} response"))
|
||||
.ToList(),
|
||||
TestAgentId, TestAgentName);
|
||||
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: true,
|
||||
autonomousModeTurnLimit: turnLimit);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: agent is called once for the initial turn plus once per autonomous turn
|
||||
int expectedInvocations = 1 + turnLimit;
|
||||
agent.Turn.Should().Be(expectedInvocations);
|
||||
|
||||
// The final HandoffState should have no requested handoff (turn limit exhausted)
|
||||
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
|
||||
.Which.Message.Should().BeOfType<HandoffState>()
|
||||
.Subject;
|
||||
sentState.RequestedHandoffTargetAgentId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_HandoffDuringAutonomousTurn_RoutesToTarget()
|
||||
{
|
||||
// Arrange: agent returns a plain response on turn 0, then a handoff on turn 1 (the first autonomous turn)
|
||||
TestEchoAgent targetAgent = new("target-agent", "Target Agent");
|
||||
|
||||
string handoffFunctionName = $"{HandoffWorkflowBuilder.FunctionPrefix}1"; // first (only) handoff target
|
||||
string handoffCallId = Guid.NewGuid().ToString("N");
|
||||
|
||||
List<List<ChatMessage>> agentTurns =
|
||||
[
|
||||
TestReplayAgent.ToChatMessages("Initial response — no handoff yet"),
|
||||
[new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(handoffCallId, handoffFunctionName)])
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
}],
|
||||
];
|
||||
|
||||
TestReplayAgent agent = new(agentTurns, TestAgentId, TestAgentName);
|
||||
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
|
||||
HandoffTarget handoffTarget = new(targetAgent);
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: true,
|
||||
autonomousModeTurnLimit: 5);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [handoffTarget], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: agent was called twice (initial + 1 autonomous turn that triggered handoff)
|
||||
agent.Turn.Should().Be(2);
|
||||
|
||||
// The final HandoffState should name the target agent
|
||||
HandoffState sentState = testContext.QueuedMessages[executor.Id].Should().ContainSingle()
|
||||
.Which.Message.Should().BeOfType<HandoffState>()
|
||||
.Subject;
|
||||
sentState.RequestedHandoffTargetAgentId.Should().Be(targetAgent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffAgentExecutor_AutonomousMode_AddsAutonomousPromptToConversation()
|
||||
{
|
||||
// Arrange: one turn without handoff, turn limit = 1 → one autonomous invocation
|
||||
TestRunContext testContext = await PrepareHandoffSharedStateAsync();
|
||||
TestReplayAgent agent = new(
|
||||
[
|
||||
TestReplayAgent.ToChatMessages("First response"),
|
||||
TestReplayAgent.ToChatMessages("Second response (autonomous)"),
|
||||
], TestAgentId, TestAgentName);
|
||||
|
||||
const string CustomPrompt = "Continue your work autonomously.";
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None,
|
||||
autonomousMode: true,
|
||||
autonomousModePrompt: CustomPrompt,
|
||||
autonomousModeTurnLimit: 1);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, [], options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert: the autonomous prompt was added to the shared conversation as a user message
|
||||
HandoffSharedState? sharedState = await testContext
|
||||
.BindWorkflowContext(nameof(HandoffStartExecutor))
|
||||
.ReadStateAsync<HandoffSharedState>(HandoffConstants.HandoffSharedStateKey,
|
||||
HandoffConstants.HandoffSharedStateScope);
|
||||
|
||||
sharedState.Should().NotBeNull();
|
||||
sharedState!.Conversation.History.Should().Contain(
|
||||
m => m.Role == ChatRole.User && m.Text == CustomPrompt,
|
||||
because: "the autonomous mode prompt should be injected as a user message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_HandoffWorkflowBuilder_EnableAutonomousMode_SetsOptionsOnExecutors()
|
||||
{
|
||||
// Arrange
|
||||
TestEchoAgent initialAgent = new("initial", "Initial");
|
||||
TestEchoAgent targetAgent = new("target", "Target");
|
||||
|
||||
// Act – build a workflow with autonomous mode enabled and verify no exception is thrown
|
||||
Workflow workflow = new HandoffWorkflowBuilder(initialAgent)
|
||||
.WithHandoff(initialAgent, targetAgent)
|
||||
.EnableAutonomousMode(prompt: "Keep going.", turnLimit: 10)
|
||||
.Build();
|
||||
|
||||
// Assert: the workflow was built without error and contains the expected executors
|
||||
workflow.Should().NotBeNull();
|
||||
workflow.ExecutorBindings.Should().ContainKey(HandoffAgentExecutor.IdFor(initialAgent));
|
||||
workflow.ExecutorBindings.Should().ContainKey(HandoffAgentExecutor.IdFor(targetAgent));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record Challenge(string Value);
|
||||
|
||||
@@ -23,23 +23,28 @@ response = await a2a_agent.run("Hello!")
|
||||
|
||||
```python
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from starlette.applications import Starlette
|
||||
|
||||
# Create an A2A executor for your agent
|
||||
executor = A2AExecutor(agent=my_agent)
|
||||
|
||||
# Set up the request handler and server application
|
||||
# Set up the request handler (agent_card is required)
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=my_agent_card,
|
||||
)
|
||||
|
||||
app = A2AStarletteApplication(
|
||||
agent_card=my_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
# Build a Starlette app with A2A routes
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(my_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from asyncio import CancelledError
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from a2a.helpers import new_task_from_user_message
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.tasks import TaskUpdater
|
||||
from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart
|
||||
from a2a.utils import new_task
|
||||
from a2a.types import Part, TaskState
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
@@ -39,21 +40,24 @@ class A2AExecutor(AgentExecutor):
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_jsonrpc_routes, create_agent_card_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from a2a.types import AgentCapabilities, AgentCard
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentInterface
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from starlette.applications import Starlette
|
||||
|
||||
public_agent_card = AgentCard(
|
||||
name="Food Agent",
|
||||
description="A simple agent that provides food-related information.",
|
||||
url="http://localhost:9999/",
|
||||
version="1.0.0",
|
||||
defaultInputModes=["text"],
|
||||
defaultOutputModes=["text"],
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
supported_interfaces=[
|
||||
AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC"),
|
||||
],
|
||||
skills=[],
|
||||
)
|
||||
|
||||
@@ -68,12 +72,15 @@ class A2AExecutor(AgentExecutor):
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}),
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=public_agent_card,
|
||||
)
|
||||
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=public_agent_card,
|
||||
http_handler=request_handler,
|
||||
).build()
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(public_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
],
|
||||
)
|
||||
|
||||
Args:
|
||||
agent: The AI agent to execute.
|
||||
@@ -143,7 +150,7 @@ class A2AExecutor(AgentExecutor):
|
||||
task = context.current_task
|
||||
|
||||
if not task:
|
||||
task = new_task(context.message)
|
||||
task = new_task_from_user_message(context.message)
|
||||
await event_queue.enqueue_event(task)
|
||||
|
||||
updater = TaskUpdater(event_queue, task.id, context.context_id)
|
||||
@@ -162,13 +169,12 @@ class A2AExecutor(AgentExecutor):
|
||||
# Mark as complete
|
||||
await updater.complete()
|
||||
except CancelledError:
|
||||
await updater.update_status(state=TaskState.canceled, final=True)
|
||||
await updater.update_status(state=TaskState.TASK_STATE_CANCELED)
|
||||
except Exception as e:
|
||||
logger.exception("A2AExecutor encountered an error during execution.", exc_info=e)
|
||||
await updater.update_status(
|
||||
state=TaskState.failed,
|
||||
final=True,
|
||||
message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]),
|
||||
state=TaskState.TASK_STATE_FAILED,
|
||||
message=updater.new_agent_message([Part(text=str(e))]),
|
||||
)
|
||||
|
||||
async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None:
|
||||
@@ -221,9 +227,9 @@ class A2AExecutor(AgentExecutor):
|
||||
) -> None:
|
||||
# Custom logic to transform item contents
|
||||
if item.role == "assistant" and item.contents:
|
||||
parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))]
|
||||
parts = [Part(text=f"Custom: {item.contents[0].text}")]
|
||||
await updater.update_status(
|
||||
state=TaskState.working,
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=updater.new_agent_message(parts=parts),
|
||||
)
|
||||
else:
|
||||
@@ -242,12 +248,12 @@ class A2AExecutor(AgentExecutor):
|
||||
|
||||
for content in contents:
|
||||
if content.type == "text" and content.text:
|
||||
parts.append(Part(root=TextPart(text=content.text)))
|
||||
parts.append(Part(text=content.text))
|
||||
elif content.type == "data" and content.uri:
|
||||
base64_str = get_uri_data(content.uri)
|
||||
parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type))))
|
||||
parts.append(Part(raw=base64.b64decode(base64_str), media_type=content.media_type or ""))
|
||||
elif content.type == "uri" and content.uri:
|
||||
parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type))))
|
||||
parts.append(Part(url=content.uri, media_type=content.media_type or ""))
|
||||
else:
|
||||
# Silently skip unsupported content types
|
||||
logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type)
|
||||
@@ -270,6 +276,6 @@ class A2AExecutor(AgentExecutor):
|
||||
else:
|
||||
# For final messages, we send TaskStatusUpdateEvent with 'working' state
|
||||
await updater.update_status(
|
||||
state=TaskState.working,
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=updater.new_agent_message(parts=parts, metadata=metadata),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
|
||||
from typing import Any, Final, Literal, TypeAlias, overload
|
||||
@@ -14,17 +13,14 @@ from a2a.client.auth.interceptor import AuthInterceptor
|
||||
from a2a.types import (
|
||||
AgentCard,
|
||||
Artifact,
|
||||
FilePart,
|
||||
FileWithBytes,
|
||||
FileWithUri,
|
||||
GetTaskRequest,
|
||||
SendMessageRequest,
|
||||
StreamResponse,
|
||||
SubscribeToTaskRequest,
|
||||
Task,
|
||||
TaskArtifactUpdateEvent,
|
||||
TaskIdParams,
|
||||
TaskQueryParams,
|
||||
TaskState,
|
||||
TaskStatusUpdateEvent,
|
||||
TextPart,
|
||||
TransportProtocol,
|
||||
)
|
||||
from a2a.types import Message as A2AMessage
|
||||
from a2a.types import Part as A2APart
|
||||
@@ -45,6 +41,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._types import AgentRunInputs
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
|
||||
__all__ = ["A2AAgent", "A2AContinuationToken"]
|
||||
|
||||
@@ -61,20 +58,19 @@ class A2AContinuationToken(ContinuationToken):
|
||||
|
||||
|
||||
TERMINAL_TASK_STATES = [
|
||||
TaskState.completed,
|
||||
TaskState.failed,
|
||||
TaskState.canceled,
|
||||
TaskState.rejected,
|
||||
TaskState.TASK_STATE_COMPLETED,
|
||||
TaskState.TASK_STATE_FAILED,
|
||||
TaskState.TASK_STATE_CANCELED,
|
||||
TaskState.TASK_STATE_REJECTED,
|
||||
]
|
||||
IN_PROGRESS_TASK_STATES = [
|
||||
TaskState.submitted,
|
||||
TaskState.working,
|
||||
TaskState.input_required,
|
||||
TaskState.auth_required,
|
||||
TaskState.TASK_STATE_SUBMITTED,
|
||||
TaskState.TASK_STATE_WORKING,
|
||||
TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
TaskState.TASK_STATE_AUTH_REQUIRED,
|
||||
]
|
||||
|
||||
A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None]
|
||||
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
|
||||
A2AStreamItem: TypeAlias = StreamResponse
|
||||
|
||||
|
||||
class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
@@ -139,7 +135,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if url is None:
|
||||
raise ValueError("Either agent_card or url must be provided")
|
||||
# Create minimal agent card from URL
|
||||
agent_card = minimal_agent_card(url, [TransportProtocol.jsonrpc])
|
||||
agent_card = minimal_agent_card(url, ["JSONRPC"])
|
||||
|
||||
# Create or use provided httpx client
|
||||
if http_client is None:
|
||||
@@ -151,7 +147,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
# Create A2A client using factory
|
||||
config = ClientConfig(
|
||||
httpx_client=http_client,
|
||||
supported_transports=[TransportProtocol.jsonrpc],
|
||||
supported_protocol_bindings=["JSONRPC"],
|
||||
)
|
||||
factory = ClientFactory(config)
|
||||
interceptors = [auth_interceptor] if auth_interceptor is not None else None
|
||||
@@ -161,7 +157,16 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
|
||||
except Exception as transport_error:
|
||||
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
|
||||
fallback_card = minimal_agent_card(agent_card.url, [TransportProtocol.jsonrpc])
|
||||
fallback_url = (
|
||||
agent_card.supported_interfaces[0].url if agent_card.supported_interfaces else url
|
||||
)
|
||||
if not fallback_url:
|
||||
raise ValueError(
|
||||
"A2A transport negotiation failed and no fallback URL is available. "
|
||||
"Provide a 'url' argument or ensure 'agent_card.supported_interfaces' "
|
||||
"contains at least one interface with a URL."
|
||||
) from transport_error
|
||||
fallback_card = minimal_agent_card(fallback_url, ["JSONRPC"])
|
||||
try:
|
||||
self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore
|
||||
except Exception as fallback_error:
|
||||
@@ -280,8 +285,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
normalized_messages = normalize_messages(messages)
|
||||
|
||||
if continuation_token is not None:
|
||||
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
|
||||
TaskIdParams(id=continuation_token["task_id"])
|
||||
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.subscribe(
|
||||
SubscribeToTaskRequest(id=continuation_token["task_id"])
|
||||
)
|
||||
else:
|
||||
if not normalized_messages:
|
||||
@@ -290,7 +295,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
normalized_messages[-1],
|
||||
context_id=session.service_session_id if session else None,
|
||||
)
|
||||
a2a_stream = self.client.send_message(a2a_message)
|
||||
a2a_stream = self.client.send_message(SendMessageRequest(message=a2a_message))
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
@@ -361,38 +366,54 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
|
||||
async for item in a2a_stream:
|
||||
if isinstance(item, A2AMessage):
|
||||
payload_type = item.WhichOneof("payload")
|
||||
if payload_type == "message":
|
||||
# Process A2A Message
|
||||
contents = self._parse_contents_from_a2a(item.parts)
|
||||
msg = item.message
|
||||
contents = self._parse_contents_from_a2a(msg.parts)
|
||||
metadata = MessageToDict(msg.metadata) if msg.metadata else None
|
||||
update = AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant" if item.role == A2ARole.agent else "user",
|
||||
response_id=str(getattr(item, "message_id", uuid.uuid4())),
|
||||
additional_properties={"a2a_metadata": item.metadata} if item.metadata else None,
|
||||
raw_representation=item,
|
||||
role="assistant" if msg.role == A2ARole.ROLE_AGENT else "user",
|
||||
response_id=msg.message_id or str(uuid.uuid4()),
|
||||
additional_properties={"a2a_metadata": metadata} if metadata else None,
|
||||
raw_representation=msg,
|
||||
)
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
|
||||
task, update_event = item
|
||||
elif payload_type == "task":
|
||||
task = item.task
|
||||
updates = self._updates_from_task(
|
||||
task,
|
||||
update_event=update_event,
|
||||
background=background,
|
||||
emit_intermediate=emit_intermediate,
|
||||
streamed_artifact_ids=streamed_artifact_ids_by_task.get(task.id),
|
||||
)
|
||||
if isinstance(update_event, TaskArtifactUpdateEvent) and any(
|
||||
update.raw_representation is update_event for update in updates
|
||||
):
|
||||
streamed_artifact_ids_by_task.setdefault(task.id, set()).add(update_event.artifact.artifact_id)
|
||||
if task.status.state in TERMINAL_TASK_STATES:
|
||||
streamed_artifact_ids_by_task.pop(task.id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif payload_type == "status_update":
|
||||
status_event = item.status_update
|
||||
updates = self._updates_from_task_update_event(status_event)
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif payload_type == "artifact_update":
|
||||
artifact_event = item.artifact_update
|
||||
updates = self._updates_from_task_update_event(artifact_event)
|
||||
if updates:
|
||||
streamed_artifact_ids_by_task.setdefault(artifact_event.task_id, set()).add(
|
||||
artifact_event.artifact.artifact_id
|
||||
)
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
raise NotImplementedError("Only Message and Task responses are supported")
|
||||
raise NotImplementedError(f"Unsupported StreamResponse payload: {payload_type}")
|
||||
|
||||
# Set the response on the context for after_run providers
|
||||
if all_updates:
|
||||
@@ -408,7 +429,6 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
self,
|
||||
task: Task,
|
||||
*,
|
||||
update_event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None,
|
||||
background: bool = False,
|
||||
emit_intermediate: bool = False,
|
||||
streamed_artifact_ids: set[str] | None = None,
|
||||
@@ -424,17 +444,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
completion.
|
||||
"""
|
||||
status = task.status
|
||||
|
||||
if (
|
||||
emit_intermediate
|
||||
and update_event is not None
|
||||
and (event_updates := self._updates_from_task_update_event(update_event))
|
||||
):
|
||||
return event_updates
|
||||
task_metadata = MessageToDict(task.metadata) if task.metadata else None
|
||||
|
||||
if status.state in TERMINAL_TASK_STATES:
|
||||
task_messages = self._parse_messages_from_task(task)
|
||||
if task.artifacts is not None and streamed_artifact_ids:
|
||||
if task.artifacts and streamed_artifact_ids:
|
||||
task_messages = [
|
||||
message
|
||||
for message in task_messages
|
||||
@@ -448,20 +462,20 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
response_id=task.id,
|
||||
message_id=getattr(message.raw_representation, "artifact_id", None),
|
||||
additional_properties={"a2a_metadata": merged}
|
||||
if (merged := {**message.additional_properties, **(task.metadata or {})})
|
||||
if (merged := {**message.additional_properties, **(task_metadata or {})})
|
||||
else None,
|
||||
raw_representation=task,
|
||||
)
|
||||
for message in task_messages
|
||||
]
|
||||
if task.artifacts is not None:
|
||||
if task.artifacts:
|
||||
return []
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=[],
|
||||
role="assistant",
|
||||
response_id=task.id,
|
||||
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
|
||||
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
|
||||
raw_representation=task,
|
||||
)
|
||||
]
|
||||
@@ -474,18 +488,16 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
role="assistant",
|
||||
response_id=task.id,
|
||||
continuation_token=token,
|
||||
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
|
||||
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
|
||||
raw_representation=task,
|
||||
)
|
||||
]
|
||||
|
||||
# Surface message content from in-progress status updates (e.g. working state)
|
||||
# Only emitted when the caller opts in (streaming), so non-streaming
|
||||
# consumers keep receiving only terminal task outputs.
|
||||
if (
|
||||
emit_intermediate
|
||||
and status.state in IN_PROGRESS_TASK_STATES
|
||||
and status.message is not None
|
||||
and status.HasField("message")
|
||||
and status.message.parts
|
||||
):
|
||||
contents = self._parse_contents_from_a2a(status.message.parts)
|
||||
@@ -493,9 +505,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant" if status.message.role == A2ARole.agent else "user",
|
||||
role="assistant" if status.message.role == A2ARole.ROLE_AGENT else "user",
|
||||
response_id=task.id,
|
||||
additional_properties={"a2a_metadata": task.metadata} if task.metadata else None,
|
||||
additional_properties={"a2a_metadata": task_metadata} if task_metadata else None,
|
||||
raw_representation=task,
|
||||
)
|
||||
]
|
||||
@@ -510,10 +522,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
contents = self._parse_contents_from_a2a(update_event.artifact.parts)
|
||||
if not contents:
|
||||
return []
|
||||
merged_metadata = {
|
||||
**(update_event.artifact.metadata or {}),
|
||||
**(update_event.metadata or {}),
|
||||
} or None
|
||||
artifact_meta = MessageToDict(update_event.artifact.metadata) if update_event.artifact.metadata else {}
|
||||
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
|
||||
merged_metadata = {**artifact_meta, **event_meta} or None
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
@@ -528,22 +539,21 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if not isinstance(update_event, TaskStatusUpdateEvent):
|
||||
return []
|
||||
|
||||
message = update_event.status.message
|
||||
if message is None or not message.parts:
|
||||
if not update_event.status.HasField("message") or not update_event.status.message.parts:
|
||||
return []
|
||||
|
||||
message = update_event.status.message
|
||||
contents = self._parse_contents_from_a2a(message.parts)
|
||||
if not contents:
|
||||
return []
|
||||
|
||||
merged_metadata = {
|
||||
**(message.metadata or {}),
|
||||
**(update_event.metadata or {}),
|
||||
} or None
|
||||
msg_meta = MessageToDict(message.metadata) if message.metadata else {}
|
||||
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
|
||||
merged_metadata = {**msg_meta, **event_meta} or None
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant" if message.role == A2ARole.agent else "user",
|
||||
role="assistant" if message.role == A2ARole.ROLE_AGENT else "user",
|
||||
response_id=update_event.task_id,
|
||||
additional_properties={"a2a_metadata": merged_metadata} if merged_metadata else None,
|
||||
raw_representation=update_event,
|
||||
@@ -572,7 +582,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
is still in progress, or ``None`` when it has reached a terminal state.
|
||||
"""
|
||||
task_id = continuation_token["task_id"]
|
||||
task = await self.client.get_task(TaskQueryParams(id=task_id))
|
||||
task = await self.client.get_task(GetTaskRequest(id=task_id))
|
||||
updates = self._updates_from_task(task, background=True)
|
||||
if updates:
|
||||
return AgentResponse.from_updates(updates)
|
||||
@@ -607,19 +617,15 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
raise ValueError("Text content requires a non-null text value")
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=TextPart(
|
||||
text=content.text,
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
text=content.text,
|
||||
metadata=content.additional_properties or {},
|
||||
)
|
||||
)
|
||||
case "error":
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=TextPart(
|
||||
text=content.message or "An error occurred.",
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
text=content.message or "An error occurred.",
|
||||
metadata=content.additional_properties or {},
|
||||
)
|
||||
)
|
||||
case "uri":
|
||||
@@ -627,27 +633,20 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
raise ValueError("URI content requires a non-null uri value")
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithUri(
|
||||
uri=content.uri,
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
url=content.uri,
|
||||
media_type=content.media_type or "",
|
||||
metadata=content.additional_properties or {},
|
||||
)
|
||||
)
|
||||
case "data":
|
||||
if content.uri is None:
|
||||
raise ValueError("Data content requires a non-null uri value")
|
||||
base64_data = get_uri_data(content.uri)
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithBytes(
|
||||
bytes=get_uri_data(content.uri),
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
raw=base64.b64decode(base64_data),
|
||||
media_type=content.media_type or "",
|
||||
metadata=content.additional_properties or {},
|
||||
)
|
||||
)
|
||||
case "hosted_file":
|
||||
@@ -655,93 +654,91 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
raise ValueError("Hosted file content requires a non-null file_id value")
|
||||
parts.append(
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithUri(
|
||||
uri=content.file_id,
|
||||
mime_type=None, # HostedFileContent doesn't specify media_type
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
)
|
||||
url=content.file_id,
|
||||
metadata=content.additional_properties or {},
|
||||
)
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown content type: {content.type}")
|
||||
|
||||
metadata = message.additional_properties.get("a2a_metadata")
|
||||
a2a_metadata = message.additional_properties.get("a2a_metadata")
|
||||
|
||||
return A2AMessage(
|
||||
role=A2ARole("user"),
|
||||
role=A2ARole.ROLE_USER,
|
||||
parts=parts,
|
||||
message_id=message.message_id or uuid.uuid4().hex,
|
||||
context_id=message.additional_properties.get("context_id") or context_id,
|
||||
metadata=metadata,
|
||||
metadata=a2a_metadata or {},
|
||||
)
|
||||
|
||||
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
|
||||
"""Parse A2A Parts into Agent Framework Content.
|
||||
|
||||
Transforms A2A protocol Parts into framework-native Content objects,
|
||||
handling text, file (URI/bytes), and data parts with metadata preservation.
|
||||
handling text, url, raw, and data parts with metadata preservation.
|
||||
"""
|
||||
contents: list[Content] = []
|
||||
for part in parts:
|
||||
inner_part = part.root
|
||||
match inner_part.kind:
|
||||
part_metadata = MessageToDict(part.metadata) if part.metadata else None
|
||||
content_type = part.WhichOneof("content")
|
||||
match content_type:
|
||||
case "text":
|
||||
contents.append(
|
||||
Content.from_text(
|
||||
text=inner_part.text,
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
text=part.text,
|
||||
additional_properties=part_metadata,
|
||||
raw_representation=part,
|
||||
)
|
||||
)
|
||||
case "file":
|
||||
if isinstance(inner_part.file, FileWithUri):
|
||||
contents.append(
|
||||
Content.from_uri(
|
||||
uri=inner_part.file.uri,
|
||||
media_type=inner_part.file.mime_type or "",
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
)
|
||||
case "url":
|
||||
contents.append(
|
||||
Content.from_uri(
|
||||
uri=part.url,
|
||||
media_type=part.media_type or "",
|
||||
additional_properties=part_metadata,
|
||||
raw_representation=part,
|
||||
)
|
||||
elif isinstance(inner_part.file, FileWithBytes):
|
||||
contents.append(
|
||||
Content.from_data(
|
||||
data=base64.b64decode(inner_part.file.bytes),
|
||||
media_type=inner_part.file.mime_type or "",
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
)
|
||||
)
|
||||
case "raw":
|
||||
contents.append(
|
||||
Content.from_data(
|
||||
data=part.raw,
|
||||
media_type=part.media_type or "",
|
||||
additional_properties=part_metadata,
|
||||
raw_representation=part,
|
||||
)
|
||||
)
|
||||
case "data":
|
||||
from google.protobuf.json_format import MessageToJson
|
||||
|
||||
contents.append(
|
||||
Content.from_text(
|
||||
text=json.dumps(inner_part.data),
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
text=MessageToJson(part.data),
|
||||
additional_properties=part_metadata,
|
||||
raw_representation=part,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown Part kind: {inner_part.kind}")
|
||||
raise ValueError(f"Unknown Part content type: {content_type}")
|
||||
return contents
|
||||
|
||||
def _parse_messages_from_task(self, task: Task) -> list[Message]:
|
||||
"""Parse A2A Task artifacts into Messages with ASSISTANT role."""
|
||||
messages: list[Message] = []
|
||||
|
||||
if task.artifacts is not None:
|
||||
if task.artifacts:
|
||||
for artifact in task.artifacts:
|
||||
messages.append(self._parse_message_from_artifact(artifact))
|
||||
elif task.history is not None and len(task.history) > 0:
|
||||
elif task.history:
|
||||
# Include the last history item as the agent response
|
||||
history_item = task.history[-1]
|
||||
contents = self._parse_contents_from_a2a(history_item.parts)
|
||||
history_metadata = MessageToDict(history_item.metadata) if history_item.metadata else None
|
||||
messages.append(
|
||||
Message(
|
||||
role="assistant" if history_item.role == A2ARole.agent else "user",
|
||||
role="assistant" if history_item.role == A2ARole.ROLE_AGENT else "user",
|
||||
contents=contents,
|
||||
additional_properties=history_item.metadata,
|
||||
additional_properties=history_metadata,
|
||||
raw_representation=history_item,
|
||||
)
|
||||
)
|
||||
@@ -751,9 +748,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
def _parse_message_from_artifact(self, artifact: Artifact) -> Message:
|
||||
"""Parse A2A Artifact into Message using part contents."""
|
||||
contents = self._parse_contents_from_a2a(artifact.parts)
|
||||
artifact_metadata = MessageToDict(artifact.metadata) if artifact.metadata else None
|
||||
return Message(
|
||||
role="assistant",
|
||||
contents=contents,
|
||||
additional_properties=artifact.metadata,
|
||||
additional_properties=artifact_metadata,
|
||||
raw_representation=artifact,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.3.0,<2",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -9,16 +9,13 @@ import httpx
|
||||
from a2a.types import (
|
||||
AgentCard,
|
||||
Artifact,
|
||||
DataPart,
|
||||
FilePart,
|
||||
FileWithUri,
|
||||
Part,
|
||||
StreamResponse,
|
||||
Task,
|
||||
TaskArtifactUpdateEvent,
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TaskStatusUpdateEvent,
|
||||
TextPart,
|
||||
)
|
||||
from a2a.types import Message as A2AMessage
|
||||
from a2a.types import Role as A2ARole
|
||||
@@ -43,59 +40,42 @@ class MockA2AClient:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.call_count: int = 0
|
||||
self.responses: list[Any] = []
|
||||
self.resubscribe_responses: list[Any] = []
|
||||
self.responses: list[StreamResponse] = []
|
||||
self.subscribe_responses: list[StreamResponse] = []
|
||||
self.get_task_response: Task | None = None
|
||||
self.last_message: Any = None
|
||||
|
||||
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
|
||||
"""Add a mock Message response."""
|
||||
|
||||
# Create actual TextPart instance and wrap it in Part
|
||||
text_part = Part(root=TextPart(text=text))
|
||||
|
||||
# Create actual Message instance
|
||||
message = A2AMessage(
|
||||
message_id=message_id, role=A2ARole.agent if role == "agent" else A2ARole.user, parts=[text_part]
|
||||
message_id=message_id,
|
||||
role=A2ARole.ROLE_AGENT if role == "agent" else A2ARole.ROLE_USER,
|
||||
parts=[Part(text=text)],
|
||||
)
|
||||
self.responses.append(message)
|
||||
self.responses.append(StreamResponse(message=message))
|
||||
|
||||
def add_task_response(self, task_id: str, artifacts: list[dict[str, Any]]) -> None:
|
||||
"""Add a mock Task response."""
|
||||
# Create mock artifacts
|
||||
mock_artifacts = []
|
||||
for artifact_data in artifacts:
|
||||
# Create actual TextPart instance and wrap it in Part
|
||||
text_part = Part(root=TextPart(text=artifact_data.get("content", "Test content")))
|
||||
|
||||
artifact = Artifact(
|
||||
artifact_id=artifact_data.get("id", str(uuid4())),
|
||||
name=artifact_data.get("name", "test-artifact"),
|
||||
description=artifact_data.get("description", "Test artifact"),
|
||||
parts=[text_part],
|
||||
parts=[Part(text=artifact_data.get("content", "Test content"))],
|
||||
)
|
||||
mock_artifacts.append(artifact)
|
||||
|
||||
# Create task status
|
||||
status = TaskStatus(state=TaskState.completed, message=None)
|
||||
|
||||
# Create actual Task instance
|
||||
task = Task(
|
||||
id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts if mock_artifacts else None
|
||||
)
|
||||
|
||||
# Mock the ClientEvent tuple format
|
||||
update_event = None # No specific update event for completed tasks
|
||||
client_event = (task, update_event)
|
||||
self.responses.append(client_event)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED)
|
||||
task = Task(id=task_id, context_id="test-context", status=status, artifacts=mock_artifacts)
|
||||
self.responses.append(StreamResponse(task=task))
|
||||
|
||||
def add_in_progress_task_response(
|
||||
self,
|
||||
task_id: str,
|
||||
context_id: str = "test-context",
|
||||
state: TaskState = TaskState.working,
|
||||
state: TaskState = TaskState.TASK_STATE_WORKING,
|
||||
text: str | None = None,
|
||||
role: A2ARole = A2ARole.agent,
|
||||
role: A2ARole = A2ARole.ROLE_AGENT,
|
||||
) -> None:
|
||||
"""Add a mock in-progress Task response (non-terminal)."""
|
||||
message = None
|
||||
@@ -103,30 +83,28 @@ class MockA2AClient:
|
||||
message = A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=role,
|
||||
parts=[Part(root=TextPart(text=text))],
|
||||
parts=[Part(text=text)],
|
||||
)
|
||||
status = TaskStatus(state=state, message=message)
|
||||
task = Task(id=task_id, context_id=context_id, status=status)
|
||||
client_event = (task, None)
|
||||
self.responses.append(client_event)
|
||||
self.responses.append(StreamResponse(task=task))
|
||||
|
||||
async def send_message(self, message: Any) -> AsyncIterator[Any]:
|
||||
async def send_message(self, request: Any) -> AsyncIterator[StreamResponse]:
|
||||
"""Mock send_message method that yields responses."""
|
||||
self.last_message = message
|
||||
self.last_message = getattr(request, "message", request)
|
||||
self.call_count += 1
|
||||
|
||||
# All queued responses are delivered as a single streaming batch per call.
|
||||
for response in self.responses:
|
||||
yield response
|
||||
self.responses.clear()
|
||||
|
||||
async def resubscribe(self, request: Any) -> AsyncIterator[Any]:
|
||||
"""Mock resubscribe method that yields responses."""
|
||||
async def subscribe(self, request: Any) -> AsyncIterator[StreamResponse]:
|
||||
"""Mock subscribe method that yields responses."""
|
||||
self.call_count += 1
|
||||
|
||||
for response in self.resubscribe_responses:
|
||||
for response in self.subscribe_responses:
|
||||
yield response
|
||||
self.resubscribe_responses.clear()
|
||||
self.subscribe_responses.clear()
|
||||
|
||||
async def get_task(self, request: Any) -> Task:
|
||||
"""Mock get_task method that returns a task."""
|
||||
@@ -282,16 +260,16 @@ async def test_run_with_task_response_no_artifacts(a2a_agent: A2AAgent, mock_a2a
|
||||
|
||||
async def test_run_with_unknown_response_type_raises_error(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test run() method with unknown response type raises NotImplementedError."""
|
||||
mock_a2a_client.responses.append("invalid_response")
|
||||
# An empty StreamResponse has no payload set (WhichOneof returns None)
|
||||
mock_a2a_client.responses.append(StreamResponse())
|
||||
|
||||
with raises(NotImplementedError, match="Only Message and Task responses are supported"):
|
||||
with raises(NotImplementedError, match="Unsupported StreamResponse payload"):
|
||||
await a2a_agent.run("Test message")
|
||||
|
||||
|
||||
def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _parse_messages_from_task with task containing no artifacts."""
|
||||
task = MagicMock()
|
||||
task.artifacts = None
|
||||
task = Task(id="test", context_id="test", status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED))
|
||||
|
||||
result = a2a_agent._parse_messages_from_task(task)
|
||||
|
||||
@@ -300,28 +278,14 @@ def test_parse_messages_from_task_empty_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _parse_messages_from_task with task containing artifacts."""
|
||||
task = MagicMock()
|
||||
|
||||
# Create mock artifacts
|
||||
artifact1 = MagicMock()
|
||||
artifact1.artifact_id = "art-1"
|
||||
text_part1 = MagicMock()
|
||||
text_part1.root = MagicMock()
|
||||
text_part1.root.kind = "text"
|
||||
text_part1.root.text = "Content 1"
|
||||
text_part1.root.metadata = None
|
||||
artifact1.parts = [text_part1]
|
||||
|
||||
artifact2 = MagicMock()
|
||||
artifact2.artifact_id = "art-2"
|
||||
text_part2 = MagicMock()
|
||||
text_part2.root = MagicMock()
|
||||
text_part2.root.kind = "text"
|
||||
text_part2.root.text = "Content 2"
|
||||
text_part2.root.metadata = None
|
||||
artifact2.parts = [text_part2]
|
||||
|
||||
task.artifacts = [artifact1, artifact2]
|
||||
artifact1 = Artifact(artifact_id="art-1", parts=[Part(text="Content 1")])
|
||||
artifact2 = Artifact(artifact_id="art-2", parts=[Part(text="Content 2")])
|
||||
task = Task(
|
||||
id="test",
|
||||
context_id="test",
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[artifact1, artifact2],
|
||||
)
|
||||
|
||||
result = a2a_agent._parse_messages_from_task(task)
|
||||
|
||||
@@ -333,16 +297,7 @@ def test_parse_messages_from_task_with_artifacts(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _parse_message_from_artifact conversion."""
|
||||
artifact = MagicMock()
|
||||
artifact.artifact_id = "test-artifact"
|
||||
|
||||
text_part = MagicMock()
|
||||
text_part.root = MagicMock()
|
||||
text_part.root.kind = "text"
|
||||
text_part.root.text = "Artifact content"
|
||||
text_part.root.metadata = None
|
||||
|
||||
artifact.parts = [text_part]
|
||||
artifact = Artifact(artifact_id="test-artifact", parts=[Part(text="Artifact content")])
|
||||
|
||||
result = a2a_agent._parse_message_from_artifact(artifact)
|
||||
|
||||
@@ -373,7 +328,7 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
|
||||
agent = A2AAgent(name="Test Agent", client=MockA2AClient(), http_client=None)
|
||||
|
||||
# Create A2A parts
|
||||
parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))]
|
||||
parts = [Part(text="First part"), Part(text="Second part")]
|
||||
|
||||
# Convert to contents
|
||||
contents = agent._parse_contents_from_a2a(parts)
|
||||
@@ -398,7 +353,7 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.text == "Test error message"
|
||||
assert a2a_message.parts[0].text == "Test error message"
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
|
||||
@@ -413,8 +368,8 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.file.uri == "http://example.com/file.pdf"
|
||||
assert a2a_message.parts[0].root.file.mime_type == "application/pdf"
|
||||
assert a2a_message.parts[0].url == "http://example.com/file.pdf"
|
||||
assert a2a_message.parts[0].media_type == "application/pdf"
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
|
||||
@@ -429,8 +384,8 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
# Verify conversion
|
||||
assert len(a2a_message.parts) == 1
|
||||
assert a2a_message.parts[0].root.file.bytes == "SGVsbG8gV29ybGQ="
|
||||
assert a2a_message.parts[0].root.file.mime_type == "text/plain"
|
||||
assert a2a_message.parts[0].raw == b"Hello World"
|
||||
assert a2a_message.parts[0].media_type == "text/plain"
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_empty_contents_raises_error(a2a_agent: A2AAgent) -> None:
|
||||
@@ -518,10 +473,10 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
assert len(result.parts) == 4
|
||||
|
||||
# Check each part type
|
||||
assert result.parts[0].root.kind == "text" # Regular text
|
||||
assert result.parts[1].root.kind == "file" # Binary data
|
||||
assert result.parts[2].root.kind == "file" # URI content
|
||||
assert result.parts[3].root.kind == "text" # JSON text remains as text (no parsing)
|
||||
assert result.parts[0].WhichOneof("content") == "text" # Regular text
|
||||
assert result.parts[1].WhichOneof("content") == "raw" # Binary data
|
||||
assert result.parts[2].WhichOneof("content") == "url" # URI content
|
||||
assert result.parts[3].WhichOneof("content") == "text" # JSON text remains as text (no parsing)
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_forwards_context_id() -> None:
|
||||
@@ -573,19 +528,29 @@ def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
"""Test conversion of A2A DataPart."""
|
||||
"""Test conversion of A2A data Part."""
|
||||
from google.protobuf.json_format import ParseDict
|
||||
from google.protobuf.struct_pb2 import Struct, Value
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
# Create DataPart
|
||||
data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"}))
|
||||
# Create Part with data (protobuf Value containing a struct)
|
||||
value = ParseDict({"key": "value", "number": 42}, Value())
|
||||
metadata = Struct()
|
||||
metadata.update({"source": "test"})
|
||||
data_part = Part(data=value, metadata=metadata)
|
||||
|
||||
contents = agent._parse_contents_from_a2a([data_part])
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
assert contents[0].type == "text"
|
||||
assert contents[0].text == '{"key": "value", "number": 42}'
|
||||
# MessageToJson may format slightly differently — verify the parsed structure
|
||||
import json
|
||||
|
||||
parsed = json.loads(contents[0].text)
|
||||
assert parsed["key"] == "value"
|
||||
assert parsed["number"] == 42
|
||||
assert contents[0].additional_properties == {"source": "test"}
|
||||
|
||||
|
||||
@@ -593,12 +558,11 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None:
|
||||
"""Test error handling for unknown A2A part kind."""
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
# Create a mock part with unknown kind
|
||||
mock_part = MagicMock()
|
||||
mock_part.root.kind = "unknown_kind"
|
||||
# Create a Part with no content field set (WhichOneof returns None)
|
||||
empty_part = Part()
|
||||
|
||||
with raises(ValueError, match="Unknown Part kind: unknown_kind"):
|
||||
agent._parse_contents_from_a2a([mock_part])
|
||||
with raises(ValueError, match="Unknown Part content type"):
|
||||
agent._parse_contents_from_a2a([empty_part])
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_with_hosted_file() -> None:
|
||||
@@ -617,14 +581,8 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
|
||||
# Verify the conversion
|
||||
assert len(result.parts) == 1
|
||||
part = result.parts[0]
|
||||
assert part.root.kind == "file"
|
||||
|
||||
# Verify it's a FilePart with FileWithUri
|
||||
|
||||
assert isinstance(part.root, FilePart)
|
||||
assert isinstance(part.root.file, FileWithUri)
|
||||
assert part.root.file.uri == "hosted://storage/document.pdf"
|
||||
assert part.root.file.mime_type is None # HostedFileContent doesn't specify media_type
|
||||
assert part.WhichOneof("content") == "url"
|
||||
assert part.url == "hosted://storage/document.pdf"
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
|
||||
@@ -632,15 +590,8 @@ def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
# Create FilePart with hosted file URI (simulating what A2A would send back)
|
||||
file_part = Part(
|
||||
root=FilePart(
|
||||
file=FileWithUri(
|
||||
uri="hosted://storage/document.pdf",
|
||||
mime_type=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
# Create Part with hosted file URL (simulating what A2A would send back)
|
||||
file_part = Part(url="hosted://storage/document.pdf")
|
||||
|
||||
contents = agent._parse_contents_from_a2a([file_part]) # noqa: SLF001
|
||||
|
||||
@@ -671,9 +622,11 @@ def test_auth_interceptor_parameter() -> None:
|
||||
|
||||
def test_transport_negotiation_both_fail() -> None:
|
||||
"""Test that RuntimeError is raised when both primary and fallback transport negotiation fail."""
|
||||
# Create a mock agent card
|
||||
# Create a mock agent card with supported_interfaces
|
||||
mock_agent_card = MagicMock(spec=AgentCard)
|
||||
mock_agent_card.url = "http://test-agent.example.com"
|
||||
mock_interface = MagicMock()
|
||||
mock_interface.url = "http://test-agent.example.com"
|
||||
mock_agent_card.supported_interfaces = [mock_interface]
|
||||
mock_agent_card.name = "Test Agent"
|
||||
mock_agent_card.description = "A test agent"
|
||||
|
||||
@@ -751,7 +704,7 @@ def test_a2a_agent_initialization_with_timeout_parameter() -> None:
|
||||
|
||||
async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a working (non-terminal) task yields an update with a continuation token when background=True."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.working)
|
||||
mock_a2a_client.add_in_progress_task_response("task-wip", context_id="ctx-1", state=TaskState.TASK_STATE_WORKING)
|
||||
|
||||
response = await a2a_agent.run("Start long task", background=True)
|
||||
|
||||
@@ -763,7 +716,7 @@ async def test_working_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a
|
||||
|
||||
async def test_submitted_task_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a submitted task yields a continuation token when background=True."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.submitted)
|
||||
mock_a2a_client.add_in_progress_task_response("task-sub", state=TaskState.TASK_STATE_SUBMITTED)
|
||||
|
||||
response = await a2a_agent.run("Submit task", background=True)
|
||||
|
||||
@@ -775,7 +728,7 @@ async def test_input_required_task_emits_continuation_token(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that an input_required task yields a continuation token when background=True."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.input_required)
|
||||
mock_a2a_client.add_in_progress_task_response("task-input", state=TaskState.TASK_STATE_INPUT_REQUIRED)
|
||||
|
||||
response = await a2a_agent.run("Need input", background=True)
|
||||
|
||||
@@ -785,7 +738,7 @@ async def test_input_required_task_emits_continuation_token(
|
||||
|
||||
async def test_working_task_no_token_without_background(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that background=False (default) does not emit continuation tokens for in-progress tasks."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.working)
|
||||
mock_a2a_client.add_in_progress_task_response("task-fg", context_id="ctx-fg", state=TaskState.TASK_STATE_WORKING)
|
||||
|
||||
response = await a2a_agent.run("Foreground task")
|
||||
|
||||
@@ -805,7 +758,7 @@ async def test_completed_task_has_no_continuation_token(a2a_agent: A2AAgent, moc
|
||||
|
||||
async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that streaming with background=True yields updates with continuation tokens."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.working)
|
||||
mock_a2a_client.add_in_progress_task_response("task-stream", context_id="ctx-s", state=TaskState.TASK_STATE_WORKING)
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Stream task", stream=True, background=True):
|
||||
@@ -820,14 +773,14 @@ async def test_streaming_emits_continuation_token(a2a_agent: A2AAgent, mock_a2a_
|
||||
async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that run() with continuation_token uses resubscribe instead of send_message."""
|
||||
# Set up the resubscribe response (completed task)
|
||||
status = TaskStatus(state=TaskState.completed, message=None)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
|
||||
artifact = Artifact(
|
||||
artifact_id="art-resume",
|
||||
name="result",
|
||||
parts=[Part(root=TextPart(text="Resumed result"))],
|
||||
parts=[Part(text="Resumed result")],
|
||||
)
|
||||
task = Task(id="task-resume", context_id="ctx-r", status=status, artifacts=[artifact])
|
||||
mock_a2a_client.resubscribe_responses.append((task, None))
|
||||
mock_a2a_client.subscribe_responses.append(StreamResponse(task=task))
|
||||
|
||||
token = A2AContinuationToken(task_id="task-resume", context_id="ctx-r")
|
||||
response = await a2a_agent.run(continuation_token=token)
|
||||
@@ -841,17 +794,17 @@ async def test_resume_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_clien
|
||||
async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that streaming run() with continuation_token and background=True uses resubscribe."""
|
||||
# Still working
|
||||
status_wip = TaskStatus(state=TaskState.working, message=None)
|
||||
status_wip = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=None)
|
||||
task_wip = Task(id="task-rs", context_id="ctx-rs", status=status_wip)
|
||||
# Then completed
|
||||
status_done = TaskStatus(state=TaskState.completed, message=None)
|
||||
status_done = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
|
||||
artifact = Artifact(
|
||||
artifact_id="art-rs",
|
||||
name="result",
|
||||
parts=[Part(root=TextPart(text="Stream resumed"))],
|
||||
parts=[Part(text="Stream resumed")],
|
||||
)
|
||||
task_done = Task(id="task-rs", context_id="ctx-rs", status=status_done, artifacts=[artifact])
|
||||
mock_a2a_client.resubscribe_responses.extend([(task_wip, None), (task_done, None)])
|
||||
mock_a2a_client.subscribe_responses.extend([StreamResponse(task=task_wip), StreamResponse(task=task_done)])
|
||||
|
||||
token = A2AContinuationToken(task_id="task-rs", context_id="ctx-rs")
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
@@ -868,7 +821,7 @@ async def test_resume_streaming_via_continuation_token(a2a_agent: A2AAgent, mock
|
||||
|
||||
async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test poll_task returns continuation token when task is still in progress."""
|
||||
status = TaskStatus(state=TaskState.working, message=None)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=None)
|
||||
mock_a2a_client.get_task_response = Task(id="task-poll", context_id="ctx-p", status=status)
|
||||
|
||||
token = A2AContinuationToken(task_id="task-poll", context_id="ctx-p")
|
||||
@@ -880,11 +833,11 @@ async def test_poll_task_in_progress(a2a_agent: A2AAgent, mock_a2a_client: MockA
|
||||
|
||||
async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test poll_task returns result with no continuation token when task is complete."""
|
||||
status = TaskStatus(state=TaskState.completed, message=None)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
|
||||
artifact = Artifact(
|
||||
artifact_id="art-poll",
|
||||
name="result",
|
||||
parts=[Part(root=TextPart(text="Poll result"))],
|
||||
parts=[Part(text="Poll result")],
|
||||
)
|
||||
mock_a2a_client.get_task_response = Task(
|
||||
id="task-poll-done", context_id="ctx-pd", status=status, artifacts=[artifact]
|
||||
@@ -1105,9 +1058,9 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl
|
||||
task = Task(
|
||||
id="task-cont",
|
||||
context_id="ctx-cont",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None),
|
||||
)
|
||||
mock_a2a_client.resubscribe_responses.append((task, None))
|
||||
mock_a2a_client.subscribe_responses.append(StreamResponse(task=task))
|
||||
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
@@ -1176,8 +1129,10 @@ async def test_streaming_working_update_without_message_is_skipped(
|
||||
|
||||
|
||||
async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that A2ARole.user in status message maps to role='user'."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user)
|
||||
"""Test that A2ARole.ROLE_USER in status message maps to role='user'."""
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-u", context_id="ctx-u", text="User echo", role=A2ARole.ROLE_USER
|
||||
)
|
||||
mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
@@ -1224,9 +1179,9 @@ async def test_terminal_no_artifacts_after_working_with_content(
|
||||
"""Test that a terminal task with no artifacts after working-state messages does not re-emit the working content."""
|
||||
mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...")
|
||||
# Terminal task with no artifacts and no history
|
||||
status = TaskStatus(state=TaskState.completed, message=None)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_COMPLETED, message=None)
|
||||
task = Task(id="task-t", context_id="ctx-t", status=status)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=task))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
@@ -1245,12 +1200,12 @@ async def test_streaming_working_update_with_empty_parts_is_skipped(
|
||||
# Construct a message with an empty parts list (distinct from message=None)
|
||||
message = A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.agent,
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[],
|
||||
)
|
||||
status = TaskStatus(state=TaskState.working, message=message)
|
||||
status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=message)
|
||||
task = Task(id="task-ep", context_id="ctx-ep", status=status)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=task))
|
||||
mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}])
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
@@ -1265,13 +1220,12 @@ async def test_streaming_artifact_update_event_yields_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that streaming artifact update events yield incremental content."""
|
||||
task = Task(id="task-art", context_id="ctx-art", status=TaskStatus(state=TaskState.working, message=None))
|
||||
artifact = Artifact(
|
||||
artifact_id="artifact-1",
|
||||
parts=[Part(root=TextPart(text="Hello"))],
|
||||
parts=[Part(text="Hello")],
|
||||
)
|
||||
update_event = TaskArtifactUpdateEvent(task_id="task-art", context_id="ctx-art", artifact=artifact, append=False)
|
||||
mock_a2a_client.responses.append((task, update_event))
|
||||
mock_a2a_client.responses.append(StreamResponse(artifact_update=update_event))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
@@ -1291,17 +1245,15 @@ async def test_streaming_status_update_event_yields_content(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.working,
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.agent,
|
||||
parts=[Part(root=TextPart(text="Still working"))],
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Still working")],
|
||||
),
|
||||
),
|
||||
final=False,
|
||||
)
|
||||
task = Task(id="task-status", context_id="ctx-status", status=TaskStatus(state=TaskState.working, message=None))
|
||||
mock_a2a_client.responses.append((task, update_event))
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
@@ -1317,13 +1269,12 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that streamed artifact chunks are not re-emitted from the final terminal task."""
|
||||
working_task = Task(id="task-art-dup", context_id="ctx-art-dup", status=TaskStatus(state=TaskState.working))
|
||||
first_chunk = TaskArtifactUpdateEvent(
|
||||
task_id="task-art-dup",
|
||||
context_id="ctx-art-dup",
|
||||
artifact=Artifact(
|
||||
artifact_id="artifact-dup",
|
||||
parts=[Part(root=TextPart(text="Hello "))],
|
||||
parts=[Part(text="Hello ")],
|
||||
),
|
||||
append=False,
|
||||
)
|
||||
@@ -1332,32 +1283,26 @@ async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_
|
||||
context_id="ctx-art-dup",
|
||||
artifact=Artifact(
|
||||
artifact_id="artifact-dup",
|
||||
parts=[Part(root=TextPart(text="world"))],
|
||||
parts=[Part(text="world")],
|
||||
),
|
||||
append=True,
|
||||
)
|
||||
terminal_task = Task(
|
||||
id="task-art-dup",
|
||||
context_id="ctx-art-dup",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(
|
||||
artifact_id="artifact-dup",
|
||||
parts=[Part(root=TextPart(text="Hello world"))],
|
||||
parts=[Part(text="Hello world")],
|
||||
)
|
||||
],
|
||||
)
|
||||
terminal_event = TaskStatusUpdateEvent(
|
||||
task_id="task-art-dup",
|
||||
context_id="ctx-art-dup",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
final=True,
|
||||
)
|
||||
|
||||
mock_a2a_client.responses.extend([
|
||||
(working_task, first_chunk),
|
||||
(working_task, second_chunk),
|
||||
(terminal_task, terminal_event),
|
||||
StreamResponse(artifact_update=first_chunk),
|
||||
StreamResponse(artifact_update=second_chunk),
|
||||
StreamResponse(task=terminal_task),
|
||||
])
|
||||
|
||||
stream = a2a_agent.run("Hello", stream=True)
|
||||
@@ -1378,21 +1323,15 @@ async def test_streaming_terminal_task_artifacts_are_emitted_when_terminal_event
|
||||
terminal_task = Task(
|
||||
id="task-art-final",
|
||||
context_id="ctx-art-final",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(
|
||||
artifact_id="artifact-final",
|
||||
parts=[Part(root=TextPart(text="Final artifact"))],
|
||||
parts=[Part(text="Final artifact")],
|
||||
)
|
||||
],
|
||||
)
|
||||
terminal_event = TaskStatusUpdateEvent(
|
||||
task_id="task-art-final",
|
||||
context_id="ctx-art-final",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
final=True,
|
||||
)
|
||||
mock_a2a_client.responses.append((terminal_task, terminal_event))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=terminal_task))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
@@ -1407,41 +1346,34 @@ async def test_streaming_terminal_task_only_emits_unstreamed_artifacts(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that the terminal task only emits artifacts that were not already streamed incrementally."""
|
||||
working_task = Task(id="task-art-mixed", context_id="ctx-art-mixed", status=TaskStatus(state=TaskState.working))
|
||||
streamed_chunk = TaskArtifactUpdateEvent(
|
||||
task_id="task-art-mixed",
|
||||
context_id="ctx-art-mixed",
|
||||
artifact=Artifact(
|
||||
artifact_id="artifact-streamed",
|
||||
parts=[Part(root=TextPart(text="Hello"))],
|
||||
parts=[Part(text="Hello")],
|
||||
),
|
||||
append=False,
|
||||
)
|
||||
terminal_task = Task(
|
||||
id="task-art-mixed",
|
||||
context_id="ctx-art-mixed",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(
|
||||
artifact_id="artifact-streamed",
|
||||
parts=[Part(root=TextPart(text="Hello"))],
|
||||
parts=[Part(text="Hello")],
|
||||
),
|
||||
Artifact(
|
||||
artifact_id="artifact-final",
|
||||
parts=[Part(root=TextPart(text="Goodbye"))],
|
||||
parts=[Part(text="Goodbye")],
|
||||
),
|
||||
],
|
||||
)
|
||||
terminal_event = TaskStatusUpdateEvent(
|
||||
task_id="task-art-mixed",
|
||||
context_id="ctx-art-mixed",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
final=True,
|
||||
)
|
||||
|
||||
mock_a2a_client.responses.extend([
|
||||
(working_task, streamed_chunk),
|
||||
(terminal_task, terminal_event),
|
||||
StreamResponse(artifact_update=streamed_chunk),
|
||||
StreamResponse(task=terminal_task),
|
||||
])
|
||||
|
||||
stream = a2a_agent.run("Hello", stream=True)
|
||||
@@ -1463,11 +1395,11 @@ async def test_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client:
|
||||
"""A2AMessage.metadata should appear on response.additional_properties."""
|
||||
msg = A2AMessage(
|
||||
message_id="msg-meta",
|
||||
role=A2ARole.agent,
|
||||
parts=[Part(root=TextPart(text="hi"))],
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="hi")],
|
||||
metadata={"source": "server", "trace_id": "abc"},
|
||||
)
|
||||
mock_a2a_client.responses.append(msg)
|
||||
mock_a2a_client.responses.append(StreamResponse(message=msg))
|
||||
|
||||
response = await a2a_agent.run("hello")
|
||||
assert response.additional_properties["a2a_metadata"]["source"] == "server"
|
||||
@@ -1479,16 +1411,16 @@ async def test_artifact_metadata_propagated(a2a_agent: A2AAgent, mock_a2a_client
|
||||
task = Task(
|
||||
id="task-art-meta",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(
|
||||
artifact_id="a1",
|
||||
parts=[Part(root=TextPart(text="result"))],
|
||||
parts=[Part(text="result")],
|
||||
metadata={"artifact_key": "artifact_value"},
|
||||
),
|
||||
],
|
||||
)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=task))
|
||||
|
||||
response = await a2a_agent.run("go")
|
||||
assert response.additional_properties["a2a_metadata"]["artifact_key"] == "artifact_value"
|
||||
@@ -1499,13 +1431,13 @@ async def test_task_metadata_propagated_to_response(a2a_agent: A2AAgent, mock_a2
|
||||
task = Task(
|
||||
id="task-meta",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]),
|
||||
Artifact(artifact_id="a1", parts=[Part(text="done")]),
|
||||
],
|
||||
metadata={"task_key": "task_value"},
|
||||
)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=task))
|
||||
|
||||
response = await a2a_agent.run("go")
|
||||
assert response.additional_properties["a2a_metadata"]["task_key"] == "task_value"
|
||||
@@ -1518,33 +1450,22 @@ async def test_task_artifact_update_event_metadata_merged(a2a_agent: A2AAgent, m
|
||||
context_id="ctx",
|
||||
artifact=Artifact(
|
||||
artifact_id="a1",
|
||||
parts=[Part(root=TextPart(text="chunk"))],
|
||||
parts=[Part(text="chunk")],
|
||||
metadata={"from_artifact": True},
|
||||
),
|
||||
metadata={"from_event": True},
|
||||
)
|
||||
working_task = Task(
|
||||
id="task-ae",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.working),
|
||||
)
|
||||
terminal_task = Task(
|
||||
id="task-ae",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="chunk"))]),
|
||||
Artifact(artifact_id="a1", parts=[Part(text="chunk")]),
|
||||
],
|
||||
)
|
||||
terminal_event = TaskStatusUpdateEvent(
|
||||
task_id="task-ae",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
final=True,
|
||||
)
|
||||
mock_a2a_client.responses.extend([
|
||||
(working_task, artifact_event),
|
||||
(terminal_task, terminal_event),
|
||||
StreamResponse(artifact_update=artifact_event),
|
||||
StreamResponse(task=terminal_task),
|
||||
])
|
||||
|
||||
stream = a2a_agent.run("hello", stream=True)
|
||||
@@ -1563,39 +1484,27 @@ async def test_task_status_update_event_metadata_merged(a2a_agent: A2AAgent, moc
|
||||
task_id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(
|
||||
state=TaskState.working,
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=A2AMessage(
|
||||
message_id="m1",
|
||||
role=A2ARole.agent,
|
||||
parts=[Part(root=TextPart(text="working..."))],
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="working...")],
|
||||
metadata={"msg_key": "msg_val"},
|
||||
),
|
||||
),
|
||||
final=False,
|
||||
metadata={"event_key": "event_val"},
|
||||
)
|
||||
working_task = Task(
|
||||
id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.working),
|
||||
)
|
||||
terminal_task = Task(
|
||||
id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(artifact_id="a1", parts=[Part(root=TextPart(text="done"))]),
|
||||
Artifact(artifact_id="a1", parts=[Part(text="done")]),
|
||||
],
|
||||
)
|
||||
terminal_event = TaskStatusUpdateEvent(
|
||||
task_id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
final=True,
|
||||
)
|
||||
mock_a2a_client.responses.extend([
|
||||
(working_task, status_event),
|
||||
(terminal_task, terminal_event),
|
||||
StreamResponse(status_update=status_event),
|
||||
StreamResponse(task=terminal_task),
|
||||
])
|
||||
|
||||
stream = a2a_agent.run("hello", stream=True)
|
||||
@@ -1613,17 +1522,17 @@ async def test_history_message_metadata_propagated(a2a_agent: A2AAgent, mock_a2a
|
||||
task = Task(
|
||||
id="task-hist",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.completed),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
history=[
|
||||
A2AMessage(
|
||||
message_id="h1",
|
||||
role=A2ARole.agent,
|
||||
parts=[Part(root=TextPart(text="reply"))],
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="reply")],
|
||||
metadata={"history_key": "history_value"},
|
||||
),
|
||||
],
|
||||
)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=task))
|
||||
|
||||
response = await a2a_agent.run("go")
|
||||
assert response.additional_properties["a2a_metadata"]["history_key"] == "history_value"
|
||||
@@ -1636,10 +1545,10 @@ async def test_continuation_token_update_carries_task_metadata(
|
||||
task = Task(
|
||||
id="task-cont",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.working),
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
|
||||
metadata={"bg_key": "bg_value"},
|
||||
)
|
||||
mock_a2a_client.responses.append((task, None))
|
||||
mock_a2a_client.responses.append(StreamResponse(task=task))
|
||||
|
||||
response = await a2a_agent.run("go", background=True)
|
||||
assert response.continuation_token is not None
|
||||
@@ -1652,10 +1561,10 @@ async def test_none_metadata_leaves_additional_properties_empty(
|
||||
"""When A2A types have no metadata, additional_properties should remain empty/default."""
|
||||
msg = A2AMessage(
|
||||
message_id="msg-none",
|
||||
role=A2ARole.agent,
|
||||
parts=[Part(root=TextPart(text="no meta"))],
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="no meta")],
|
||||
)
|
||||
mock_a2a_client.responses.append(msg)
|
||||
mock_a2a_client.responses.append(StreamResponse(message=msg))
|
||||
|
||||
response = await a2a_agent.run("hello")
|
||||
assert not response.additional_properties
|
||||
|
||||
@@ -3,7 +3,7 @@ from asyncio import CancelledError
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from a2a.types import Task, TaskState, TextPart
|
||||
from a2a.types import Part, Task, TaskState
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
Content,
|
||||
@@ -48,7 +48,7 @@ def mock_task() -> Task:
|
||||
task = MagicMock(spec=Task)
|
||||
task.id = str(uuid4())
|
||||
task.context_id = str(uuid4())
|
||||
task.state = TaskState.completed
|
||||
task.state = TaskState.TASK_STATE_COMPLETED
|
||||
return task
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ class TestA2AExecutorExecute:
|
||||
executor._agent.run = AsyncMock(return_value=response)
|
||||
executor._agent.create_session = MagicMock()
|
||||
|
||||
with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task:
|
||||
with patch("agent_framework_a2a._a2a_executor.new_task_from_user_message") as mock_new_task:
|
||||
mock_task = MagicMock(spec=Task)
|
||||
mock_task.id = "task-new"
|
||||
mock_task.context_id = "ctx-123"
|
||||
@@ -341,9 +341,7 @@ class TestA2AExecutorExecute:
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called()
|
||||
call_args_list = mock_updater.update_status.call_args_list
|
||||
assert any(
|
||||
call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list
|
||||
)
|
||||
assert any(call[1].get("state") == TaskState.TASK_STATE_CANCELED for call in call_args_list)
|
||||
|
||||
async def test_execute_handles_generic_exception(
|
||||
self,
|
||||
@@ -382,14 +380,12 @@ class TestA2AExecutorExecute:
|
||||
args, _ = mock_updater.new_agent_message.call_args
|
||||
parts = args[0]
|
||||
assert len(parts) == 1
|
||||
assert isinstance(parts[0].root, TextPart)
|
||||
assert parts[0].root.text == error_message
|
||||
assert isinstance(parts[0], Part)
|
||||
assert parts[0].text == error_message
|
||||
|
||||
call_args_list = mock_updater.update_status.call_args_list
|
||||
assert any(
|
||||
call[1].get("state") == TaskState.failed
|
||||
and call[1].get("final") is True
|
||||
and call[1].get("message") == "error_message_obj"
|
||||
call[1].get("state") == TaskState.TASK_STATE_FAILED and call[1].get("message") == "error_message_obj"
|
||||
for call in call_args_list
|
||||
)
|
||||
|
||||
@@ -630,7 +626,7 @@ class TestA2AExecutorHandleEvents:
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
|
||||
assert mock_updater.new_agent_message.called
|
||||
|
||||
async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
@@ -666,7 +662,7 @@ class TestA2AExecutorHandleEvents:
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
|
||||
|
||||
async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with URI content."""
|
||||
@@ -683,7 +679,7 @@ class TestA2AExecutorHandleEvents:
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
|
||||
|
||||
async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with mixed content types."""
|
||||
@@ -705,7 +701,7 @@ class TestA2AExecutorHandleEvents:
|
||||
# Assert
|
||||
mock_updater.update_status.assert_called_once()
|
||||
call_args = mock_updater.update_status.call_args
|
||||
assert call_args.kwargs["state"] == TaskState.working
|
||||
assert call_args.kwargs["state"] == TaskState.TASK_STATE_WORKING
|
||||
|
||||
async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None:
|
||||
"""Test handling messages with additional properties metadata."""
|
||||
@@ -778,7 +774,7 @@ class TestA2AExecutorHandleEvents:
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_updater.update_status.call_args.kwargs
|
||||
assert call_kwargs["state"] == TaskState.working
|
||||
assert call_kwargs["state"] == TaskState.TASK_STATE_WORKING
|
||||
|
||||
async def test_handle_agent_response_update_no_streamed_set(
|
||||
self, executor: A2AExecutor, mock_updater: MagicMock
|
||||
|
||||
@@ -5,14 +5,15 @@ import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
from a2a.server.apps.jsonrpc.starlette_app import A2AStarletteApplication
|
||||
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
|
||||
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from agent_definitions import AGENT_CARD_FACTORIES, AGENT_FACTORIES
|
||||
from agent_executor import AgentFrameworkExecutor
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from starlette.applications import Starlette
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
@@ -96,11 +97,14 @@ def main() -> None:
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=task_store,
|
||||
agent_card=agent_card,
|
||||
)
|
||||
|
||||
a2a_app = A2AStarletteApplication(
|
||||
agent_card=agent_card,
|
||||
http_handler=request_handler,
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
]
|
||||
)
|
||||
|
||||
print(f"Starting A2A server: {agent_card.name}")
|
||||
@@ -110,7 +114,7 @@ def main() -> None:
|
||||
print()
|
||||
|
||||
uvicorn.run(
|
||||
a2a_app.build(),
|
||||
app,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
|
||||
from invoice_data import query_by_invoice_id, query_by_transaction_id, query_invoices
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -94,11 +94,11 @@ def get_invoice_agent_card(url: str) -> AgentCard:
|
||||
return AgentCard(
|
||||
name="InvoiceAgent",
|
||||
description="Handles requests relating to invoices.",
|
||||
url=url,
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_invoice_agent",
|
||||
@@ -116,11 +116,11 @@ def get_policy_agent_card(url: str) -> AgentCard:
|
||||
return AgentCard(
|
||||
name="PolicyAgent",
|
||||
description="Handles requests relating to policies and customer communications.",
|
||||
url=url,
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_policy_agent",
|
||||
@@ -138,11 +138,11 @@ def get_logistics_agent_card(url: str) -> AgentCard:
|
||||
return AgentCard(
|
||||
name="LogisticsAgent",
|
||||
description="Handles requests relating to logistics.",
|
||||
url=url,
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_logistics_agent",
|
||||
|
||||
@@ -21,7 +21,6 @@ from a2a.types import (
|
||||
TaskState,
|
||||
TaskStatus,
|
||||
TaskStatusUpdateEvent,
|
||||
TextPart,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -56,8 +55,7 @@ class AgentFrameworkExecutor(AgentExecutor):
|
||||
TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(state=TaskState.working),
|
||||
final=False,
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -68,10 +66,10 @@ class AgentFrameworkExecutor(AgentExecutor):
|
||||
response_parts: list[Part] = []
|
||||
for msg in response.messages:
|
||||
if msg.text:
|
||||
response_parts.append(TextPart(text=msg.text))
|
||||
response_parts.append(Part(text=msg.text))
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append(TextPart(text=str(response)))
|
||||
response_parts.append(Part(text=str(response)))
|
||||
|
||||
# Publish the agent's response as a completed message
|
||||
await event_queue.enqueue_event(
|
||||
@@ -79,14 +77,13 @@ class AgentFrameworkExecutor(AgentExecutor):
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(
|
||||
state=TaskState.completed,
|
||||
state=TaskState.TASK_STATE_COMPLETED,
|
||||
message=Message(
|
||||
message_id=str(uuid.uuid4()),
|
||||
role=Role.agent,
|
||||
role=Role.ROLE_AGENT,
|
||||
parts=response_parts,
|
||||
),
|
||||
),
|
||||
final=True,
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
@@ -97,14 +94,13 @@ class AgentFrameworkExecutor(AgentExecutor):
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(
|
||||
state=TaskState.failed,
|
||||
state=TaskState.TASK_STATE_FAILED,
|
||||
message=Message(
|
||||
message_id=str(uuid.uuid4()),
|
||||
role=Role.agent,
|
||||
parts=[TextPart(text=f"Agent error: {e}")],
|
||||
role=Role.ROLE_AGENT,
|
||||
parts=[Part(text=f"Agent error: {e}")],
|
||||
),
|
||||
),
|
||||
final=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -117,7 +113,6 @@ class AgentFrameworkExecutor(AgentExecutor):
|
||||
TaskStatusUpdateEvent(
|
||||
task_id=task_id,
|
||||
context_id=context_id,
|
||||
status=TaskStatus(state=TaskState.canceled),
|
||||
final=True,
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_CANCELED),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import uvicorn
|
||||
from a2a.server.apps import A2AStarletteApplication
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from a2a.types import (
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
)
|
||||
from agent_framework import Agent
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
from starlette.applications import Starlette
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -39,11 +41,11 @@ if __name__ == "__main__":
|
||||
public_agent_card = AgentCard(
|
||||
name="Europe Travel Agent",
|
||||
description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.",
|
||||
url="http://localhost:9999/",
|
||||
version="1.0.0",
|
||||
defaultInputModes=["text"],
|
||||
defaultOutputModes=["text"],
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
supported_interfaces=[AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC")],
|
||||
skills=[flight_skill, hotel_skill],
|
||||
)
|
||||
# --8<-- [end:AgentCard]
|
||||
@@ -57,14 +59,14 @@ if __name__ == "__main__":
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=A2AExecutor(agent),
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=public_agent_card,
|
||||
http_handler=request_handler,
|
||||
)
|
||||
|
||||
server = server.build()
|
||||
# print(schemas.get_schema(server.routes))
|
||||
server = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(public_agent_card),
|
||||
*create_jsonrpc_routes(request_handler),
|
||||
]
|
||||
)
|
||||
|
||||
uvicorn.run(server, host="0.0.0.0", port=9999)
|
||||
|
||||
Generated
+44
-4
@@ -67,18 +67,22 @@ overrides = [
|
||||
|
||||
[[package]]
|
||||
name = "a2a-sdk"
|
||||
version = "0.3.23"
|
||||
version = "1.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "culsans", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "json-rpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2fe24e0a85240a651006c12f79bdb37156adc760a96c44bc002ebda77916/a2a_sdk-0.3.23.tar.gz", hash = "sha256:7c46b8572c4633a2b41fced2833e11e62871e8539a5b3c782ba2ba1e33d213c2", size = 255265, upload-time = "2026-02-17T08:34:34.648Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/f3/1c312eae0298542eef1a096be378a3ad2d20b171ea0ac6be26b81f542720/a2a_sdk-1.0.2.tar.gz", hash = "sha256:e4ee4dd509894c32c9a6df728319875fa4f049e70ae82476fa447353e3a4b648", size = 375193, upload-time = "2026-04-24T13:50:24.303Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/20/77d119f19ab03449d3e6bc0b1f11296d593dae99775c1d891ab1e290e416/a2a_sdk-0.3.23-py3-none-any.whl", hash = "sha256:8c2f01dffbfdd3509eafc15c4684743e6ae75e69a5df5d6f87be214c948e7530", size = 145689, upload-time = "2026-02-17T08:34:33.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/03/58c92a44e7b94a42614880df2365f074969e47067c4c736e31e855aca2fd/a2a_sdk-1.0.2-py3-none-any.whl", hash = "sha256:4dbc083b6808ee28207ac6daad263360f87612c37b2d06f5521efb530318141c", size = 234302, upload-time = "2026-04-24T13:50:22.412Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -168,7 +172,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "a2a-sdk", specifier = ">=0.3.5,<0.3.24" },
|
||||
{ name = "a2a-sdk", specifier = ">=1.0.0,<2" },
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
]
|
||||
|
||||
@@ -975,6 +979,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiologic"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sniffio", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "wrapt", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
@@ -2012,6 +2030,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "culsans"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiologic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cycler"
|
||||
version = "0.12.1"
|
||||
@@ -3163,6 +3194,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json-rpc"
|
||||
version = "1.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpath-ng"
|
||||
version = "1.8.0"
|
||||
|
||||
Reference in New Issue
Block a user