mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021d9f2dc4 | ||
|
|
a47da4de3d | ||
|
|
0681e046ea | ||
|
|
05c29347ff | ||
|
|
0987ce3d31 | ||
|
|
4ad96b64e7 | ||
|
|
e3875f2c91 | ||
|
|
9199c84d42 | ||
|
|
0bbedc4fa2 | ||
|
|
18d7a46a54 | ||
|
|
9d8c3f8cb7 | ||
|
|
9faf52de4f | ||
|
|
d2ce0e9087 | ||
|
|
0557b5782b | ||
|
|
eb709d8fc9 | ||
|
|
226c004b53 | ||
|
|
3aae3cb9de | ||
|
|
0340b7596b | ||
|
|
76772ffc19 | ||
|
|
27324a8013 | ||
|
|
57fb32efc8 | ||
|
|
3c1e2c40b8 | ||
|
|
d3518ad19d | ||
|
|
c06af9a1b3 | ||
|
|
1d94518f37 | ||
|
|
a478d1b53c | ||
|
|
ce70ca1a9f | ||
|
|
2a9b68d1bd | ||
|
|
1489d6620e | ||
|
|
8bb4692678 |
@@ -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'
|
||||
@@ -273,6 +274,8 @@ jobs:
|
||||
-c ${{ matrix.configuration }} `
|
||||
--no-build -v Normal `
|
||||
--report-xunit-trx `
|
||||
--report-junit `
|
||||
--results-directory ../IntegrationTestResults/ `
|
||||
--ignore-exit-code 8 `
|
||||
--filter-not-trait "Category=IntegrationDisabled" `
|
||||
--filter-not-trait "Category=FoundryHostedAgents" `
|
||||
@@ -294,6 +297,10 @@ jobs:
|
||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
|
||||
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
|
||||
# Anthropic Models
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_NAME: ${{ vars.ANTHROPIC_CHAT_MODEL_NAME }}
|
||||
ANTHROPIC_REASONING_MODEL_NAME: ${{ vars.ANTHROPIC_REASONING_MODEL_NAME }}
|
||||
|
||||
# Generate test reports and check coverage
|
||||
- name: Generate test reports
|
||||
@@ -316,6 +323,14 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
|
||||
|
||||
- name: Upload integration test results
|
||||
if: always() && github.event_name != 'pull_request' && matrix.integration-tests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }}
|
||||
path: IntegrationTestResults/**/*.junit
|
||||
if-no-files-found: ignore
|
||||
|
||||
# The Foundry hosted-agent IT is costly (it builds a container, pushes to ACR, and provisions
|
||||
# live agents on a separate Foundry project). Running it in its own job keeps the overall
|
||||
# workflow time roughly flat: it executes in parallel to dotnet-build and dotnet-test and is
|
||||
@@ -326,7 +341,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
targetFramework: net10.0
|
||||
configuration: Release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -343,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
|
||||
@@ -379,6 +377,13 @@ jobs:
|
||||
# We rebuild and push the test container image on every IT run so framework code changes
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -394,8 +399,7 @@ jobs:
|
||||
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 `
|
||||
@@ -404,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
|
||||
@@ -448,3 +458,64 @@ jobs:
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: core.setFailed('Integration Tests Cancelled!')
|
||||
|
||||
# Integration test trend report (aggregates JUnit XML results from dotnet test jobs)
|
||||
dotnet-integration-test-report:
|
||||
name: Integration Test Report
|
||||
if: >
|
||||
always() &&
|
||||
github.event_name != 'pull_request' &&
|
||||
(contains(join(needs.*.result, ','), 'success') ||
|
||||
contains(join(needs.*.result, ','), 'failure'))
|
||||
needs: [dotnet-test]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/actions/python-setup
|
||||
python
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: "3.13"
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
dotnet-integration-report-history-
|
||||
- name: Generate trend report
|
||||
run: >
|
||||
uv run python scripts/integration_test_report/aggregate.py
|
||||
../dotnet-test-results/
|
||||
dotnet-integration-report-history.json
|
||||
dotnet-integration-test-report.md
|
||||
- name: Post to Job Summary
|
||||
if: always()
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
- name: Upload trend report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dotnet-integration-test-report
|
||||
path: |
|
||||
python/dotnet-integration-test-report.md
|
||||
python/dotnet-integration-report-history.json
|
||||
|
||||
@@ -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" />
|
||||
@@ -98,7 +99,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="0.1.29" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.2" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
|
||||
@@ -33,3 +33,4 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
- [Design Documents](../docs/design)
|
||||
- [Architectural Decision Records](../docs/decisions)
|
||||
- [MSFT Learn Docs](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session));
|
||||
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
|
||||
|
||||
// We can serialize the session. The serialized state will include the state of the memory component.
|
||||
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
|
||||
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
|
||||
|
||||
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
|
||||
|
||||
// Later we can deserialize the session and continue the conversation with the previous memory component state.
|
||||
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
|
||||
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
|
||||
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
|
||||
|
||||
Console.WriteLine("\n>> Read memories using memory component\n");
|
||||
|
||||
@@ -12,7 +12,9 @@ static Task<PermissionRequestResult> PromptPermission(PermissionRequest request,
|
||||
Console.Write("Approve? (y/n): ");
|
||||
|
||||
string? input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
string kind = input is "Y" or "YES" ? "approved" : "denied-interactively-by-user";
|
||||
PermissionRequestResultKind kind = input is "Y" or "YES"
|
||||
? PermissionRequestResultKind.Approved
|
||||
: PermissionRequestResultKind.Rejected;
|
||||
|
||||
return Task.FromResult(new PermissionRequestResult { Kind = kind });
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -190,7 +190,7 @@ internal static class InputConverter
|
||||
|
||||
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
|
||||
{
|
||||
var output = funcOutput.Output?.ToString() ?? string.Empty;
|
||||
var output = DecodeFunctionResultPayload(funcOutput.Output);
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
@@ -482,9 +482,54 @@ internal static class InputConverter
|
||||
|
||||
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
|
||||
{
|
||||
var output = DecodeFunctionResultPayload(funcOutput.Output);
|
||||
return new ChatMessage(
|
||||
ChatRole.Tool,
|
||||
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
|
||||
[new FunctionResultContent(funcOutput.CallId, output)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the wire payload of a <c>function_call_output.output</c> field back into the
|
||||
/// underlying tool-result text suitable for replay as <see cref="FunctionResultContent.Result"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <c>OutputConverter.EncodeFunctionResultAsJsonStringPayload</c>. Per the OpenAI
|
||||
/// Responses spec, <c>output</c> is a JSON string; we extract its underlying value. Legacy
|
||||
/// producers that emitted raw JSON values (arrays/objects) are tolerated by passing the raw
|
||||
/// bytes through unchanged.
|
||||
/// </remarks>
|
||||
private static string DecodeFunctionResultPayload(BinaryData? rawOutput)
|
||||
{
|
||||
if (rawOutput is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var raw = rawOutput.ToString();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return doc.RootElement.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// Legacy/non-conforming producers may have emitted a raw JSON value
|
||||
// (array/object/number/bool/null). Pass the raw text through as the
|
||||
// payload so the replayed FunctionResultContent.Result preserves the
|
||||
// original tool output shape.
|
||||
return raw;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON — treat the bytes as a literal string payload.
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatRole ConvertMessageRole(MessageRole role)
|
||||
|
||||
+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" />
|
||||
|
||||
@@ -279,12 +279,7 @@ internal static class OutputConverter
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
var outputText = functionResult.Result switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string s => s,
|
||||
_ => JsonSerializer.Serialize(functionResult.Result),
|
||||
};
|
||||
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
|
||||
|
||||
var itemId = GenerateItemId("fc");
|
||||
var outputItem = new OutputItemFunctionToolCallOutput(
|
||||
@@ -448,4 +443,44 @@ internal static class OutputConverter
|
||||
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
|
||||
return $"{prefix}_{body}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a <see cref="FunctionResultContent.Result"/> value into the wire payload for
|
||||
/// the OpenAI Responses <c>function_call_output.output</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The OpenAI Responses spec requires <c>output</c> to be a JSON string. The Responses
|
||||
/// SDK's <see cref="OutputItemFunctionToolCallOutput"/> accepts a <see cref="BinaryData"/>
|
||||
/// containing the *raw JSON value* for the field, so the returned text is always a JSON
|
||||
/// string literal (quoted, with escapes). This avoids two bugs:
|
||||
/// <list type="bullet">
|
||||
/// <item>Complex results (e.g. <c>List<TodoItem></c>) landing on the wire as an
|
||||
/// unquoted JSON array, which the strict-parsing OpenAI .NET client
|
||||
/// (<c>FunctionCallOutputResponseItem</c>) rejects with
|
||||
/// "requires an element of type 'String', but the target element has type 'Array'".</item>
|
||||
/// <item>Numeric- or JSON-shaped string results (e.g. <c>"42"</c> or <c>"{\"k\":1}"</c>)
|
||||
/// silently changing type on the wire because <c>BinaryData</c> auto-detects JSON.</item>
|
||||
/// </list>
|
||||
/// <see cref="JsonElement"/> / <see cref="JsonDocument"/> values are unwrapped first so
|
||||
/// a string-kind element does not get double-encoded into <c>"\"value\""</c>.
|
||||
/// </remarks>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")]
|
||||
private static string EncodeFunctionResultAsJsonStringPayload(object? result)
|
||||
{
|
||||
string innerText = result switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string s => s,
|
||||
JsonElement je => je.ValueKind == JsonValueKind.String
|
||||
? (je.GetString() ?? string.Empty)
|
||||
: je.GetRawText(),
|
||||
JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String
|
||||
? (jd.RootElement.GetString() ?? string.Empty)
|
||||
: jd.RootElement.GetRawText(),
|
||||
_ => JsonSerializer.Serialize(result),
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(innerText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -210,7 +210,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string prompt = string.Join("\n", messages.Select(m => m.Text));
|
||||
|
||||
// Handle DataContent as attachments
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<UserMessageAttachmentFile>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -443,11 +443,11 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage };
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
private static async Task<(List<UserMessageAttachmentFile>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageDataAttachmentsItem>? attachments = null;
|
||||
List<UserMessageAttachmentFile>? attachments = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
@@ -461,7 +461,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
attachments.Add(new UserMessageAttachmentFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath)
|
||||
|
||||
@@ -13,5 +13,5 @@ internal sealed class SequenceNumber
|
||||
/// Gets the next sequence number.
|
||||
/// </summary>
|
||||
/// <returns>The next sequence number.</returns>
|
||||
public int Increment() => this._sequenceNumber++;
|
||||
public int Increment() => System.Threading.Interlocked.Increment(ref this._sequenceNumber) - 1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
@@ -10,6 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
[JsonDerivedType(typeof(ExecutorInvokedEvent))]
|
||||
[JsonDerivedType(typeof(ExecutorCompletedEvent))]
|
||||
[JsonDerivedType(typeof(ExecutorFailedEvent))]
|
||||
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
|
||||
public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data)
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Request for human review of a proposed plan.
|
||||
/// </summary>
|
||||
/// <param name="Plan">The proposed plan.</param>
|
||||
/// <param name="CurrentProgress">The current progress ledger, if available. During the initial plan review,
|
||||
/// this will be <see langword="null"/>. In subsequent reviews after replanning (due to stalls), this will
|
||||
/// contain the latest progress ledger that determined that no progress has been made or the workflow was in
|
||||
/// a loop.</param>
|
||||
/// <param name="IsStalled">Whether the workflow is currently stalled.</param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public record MagenticPlanReviewRequest(ChatMessage Plan, MagenticProgressLedger? CurrentProgress, bool IsStalled)
|
||||
{
|
||||
/// <summary>
|
||||
/// Create an approving <see cref="MagenticPlanReviewResponse"/>.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Approve() => new([]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Revise(string message) => new([new(ChatRole.User, message)]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Revise(ChatMessage message) => new([message]);
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="MagenticPlanReviewResponse"/> with revisions.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticPlanReviewResponse Revise(IEnumerable<ChatMessage> messages)
|
||||
=> new(messages is List<ChatMessage> messageList ? messageList : messages.ToList());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Review feedback for a proposed plan, including any revisions if the plan is not approved as-is. An
|
||||
/// empty list of review messages indicates approval of the proposed plan without any revisions.
|
||||
/// </summary>
|
||||
/// <param name="Review">
|
||||
/// Review feedback for a generated plan. Empty if the plan is approved as-is and changes are requested.
|
||||
/// </param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public record MagenticPlanReviewResponse(List<ChatMessage> Review)
|
||||
{
|
||||
internal bool IsApproved => this.Review.Count == 0;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a ledger of progress made by the Magentic workflow.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public class MagenticProgressLedger
|
||||
{
|
||||
internal static readonly BooleanProgressLedgerSlot IsRequestSatisfiedSlot = new("is_request_satisfied",
|
||||
"Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)");
|
||||
|
||||
internal static readonly BooleanProgressLedgerSlot IsInLoopSlot = new("is_in_loop",
|
||||
"Are we in a loop where we are repeating the same requests and or getting the same responses as before? " +
|
||||
"Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.");
|
||||
|
||||
internal static readonly BooleanProgressLedgerSlot IsProgressBeingMadeSlot = new("is_progress_being_made",
|
||||
"Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent " +
|
||||
"messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success " +
|
||||
"such as the inability to read from a required file)");
|
||||
|
||||
internal readonly StringProgressLedgerSlot NextSpeakerSlot;
|
||||
|
||||
internal static readonly StringProgressLedgerSlot InstructionOrQuestionSlot = new("instruction_or_question",
|
||||
"What instruction or question would you give this team member? (Phrase as if speaking directly to them, and " +
|
||||
"include any specific information they may need)");
|
||||
|
||||
internal MagenticProgressLedger(string teamNames, IEnumerable<ProgressLedgerSlot> additionalQuestions, JsonElement? state = null)
|
||||
{
|
||||
this.NextSpeakerSlot = new("next_speaker", $"Who should speak next? (select from: {teamNames})");
|
||||
this.AdditionalQuestions = additionalQuestions as ProgressLedgerSlot[] ?? additionalQuestions.ToArray();
|
||||
|
||||
if (state != null)
|
||||
{
|
||||
this.TryUpdateState(state.Value);
|
||||
}
|
||||
}
|
||||
|
||||
internal ProgressLedgerSlot[] AdditionalQuestions { get; }
|
||||
|
||||
internal bool TryUpdateState(JsonElement element)
|
||||
{
|
||||
// In principle all of these should be inlineable, but the CodeAnalysis fails to properly chain through the and-chain to realize that
|
||||
// all must be true for `requiredQuestionsAnswered` to be true, meaning all of the out parameters would be initialized properly.
|
||||
bool isInLoop = false;
|
||||
bool isProgressBeingMade = false;
|
||||
string? nextSpeaker = string.Empty;
|
||||
string? instructionOrQuestion = string.Empty;
|
||||
|
||||
bool requiredQuestionsAnswered =
|
||||
IsRequestSatisfiedSlot.TryGetValueFrom(element, out bool isRequestSatisfied) &&
|
||||
IsInLoopSlot.TryGetValueFrom(element, out isInLoop) &&
|
||||
IsProgressBeingMadeSlot.TryGetValueFrom(element, out isProgressBeingMade) &&
|
||||
this.NextSpeakerSlot.TryGetValueFrom(element, out nextSpeaker) &&
|
||||
InstructionOrQuestionSlot.TryGetValueFrom(element, out instructionOrQuestion);
|
||||
|
||||
if (requiredQuestionsAnswered)
|
||||
{
|
||||
this.State = element;
|
||||
|
||||
this.IsRequestSatisfied = isRequestSatisfied;
|
||||
this.IsInLoop = isInLoop;
|
||||
this.IsProgressBeingMade = isProgressBeingMade;
|
||||
|
||||
this.NextSpeaker = nextSpeaker!;
|
||||
this.InstructionOrQuestion = instructionOrQuestion!;
|
||||
}
|
||||
|
||||
// TODO: To what extent do we want to enforce that the additional questions are also answered?
|
||||
|
||||
return requiredQuestionsAnswered;
|
||||
}
|
||||
|
||||
[JsonInclude]
|
||||
internal JsonElement? State;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether plan execution has started.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsStarted => this.State != null;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the task has been fully satisfied.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsRequestSatisfied { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the team is in a loop.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsInLoop { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the team is making progress on the task.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsProgressBeingMade { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next team member to take a turn.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string NextSpeaker { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the instruction or question to send to the next team member.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string InstructionOrQuestion { get; private set; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
internal IEnumerable<ProgressLedgerSlot> Slots =>
|
||||
[
|
||||
IsRequestSatisfiedSlot,
|
||||
IsInLoopSlot,
|
||||
IsProgressBeingMadeSlot,
|
||||
this.NextSpeakerSlot,
|
||||
InstructionOrQuestionSlot,
|
||||
.. this.AdditionalQuestions
|
||||
];
|
||||
|
||||
internal bool TryGetCurrentSlotValue<T>(ProgressLedgerSlot<T> slot, [NotNullWhen(true)] out T? value)
|
||||
{
|
||||
if (!this.State.HasValue)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
return slot.TryGetValueFrom(this.State.Value, out value);
|
||||
}
|
||||
|
||||
private (string QuestionBlock, string AnswerSchema)? _questionFormatCache;
|
||||
internal (string QuestionBlock, string AnswerSchema) FormatQuestions()
|
||||
{
|
||||
if (!this._questionFormatCache.HasValue)
|
||||
{
|
||||
StringBuilder questionBuilder = new(), schemaBuilder = new();
|
||||
|
||||
schemaBuilder.AppendLine("{");
|
||||
foreach (ProgressLedgerSlot slot in this.Slots)
|
||||
{
|
||||
questionBuilder.AppendLine(slot.FormattedQuestion);
|
||||
|
||||
schemaBuilder.AppendLine($"\"{slot.Key}\": {{")
|
||||
.AppendLine($" \"{ProgressLedgerSlot.ValueKey}\": {slot.SchemaType}{slot.SuffixString},")
|
||||
.AppendLine($" \"{ProgressLedgerSlot.ReasonKey}\": string")
|
||||
.AppendLine("}");
|
||||
}
|
||||
schemaBuilder.AppendLine("}");
|
||||
|
||||
this._questionFormatCache = (questionBuilder.ToString(), schemaBuilder.ToString());
|
||||
}
|
||||
|
||||
return this._questionFormatCache.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract record ProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null)
|
||||
{
|
||||
public const string ValueKey = "answer";
|
||||
public const string ReasonKey = "reason";
|
||||
|
||||
internal string SuffixString => this.SchemaTypeSuffix == null ? string.Empty : $"({this.SchemaTypeSuffix})";
|
||||
|
||||
protected internal abstract string SchemaType { get; }
|
||||
|
||||
public string FormattedQuestion
|
||||
{
|
||||
get
|
||||
{
|
||||
if (field == null)
|
||||
{
|
||||
IEnumerable<string> questionLines = this.Question.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.TrimEnd());
|
||||
|
||||
field = $" - {string.Join("\n ", questionLines)}";
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal abstract record ProgressLedgerSlot<T>(string Key, string Question, string? SchemaTypeSuffix = null, JsonSerializerOptions? SerializerOptions = null)
|
||||
: ProgressLedgerSlot(Key, Question, SchemaTypeSuffix)
|
||||
{
|
||||
protected internal virtual JsonTypeInfo<T> GetJsonTypeInfo() =>
|
||||
((this.SerializerOptions ?? WorkflowsJsonUtilities.DefaultOptions).TryGetTypeInfo(typeof(T), out JsonTypeInfo? typeInfo)
|
||||
? typeInfo as JsonTypeInfo<T> : null)
|
||||
?? throw new InvalidOperationException($"Cannot get TypeInfo for {typeof(T)} from {(this.SerializerOptions == null ? "provided" : "default")} SerializationOptions.");
|
||||
|
||||
public bool TryGetValueFrom(JsonElement answers, [NotNullWhen(true)] out T? value)
|
||||
{
|
||||
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
|
||||
slotElement.ValueKind != JsonValueKind.Null &&
|
||||
slotElement.TryGetProperty(ValueKey, out JsonElement answerValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
T? result = answerValue.Deserialize(this.GetJsonTypeInfo());
|
||||
if (result != null)
|
||||
{
|
||||
value = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetReasonFrom(JsonElement answers, [NotNullWhen(true)] out string? value)
|
||||
{
|
||||
if (answers.TryGetProperty(this.Key, out JsonElement slotElement) &&
|
||||
slotElement.ValueKind != JsonValueKind.Null &&
|
||||
slotElement.TryGetProperty(ReasonKey, out JsonElement reasonValue))
|
||||
{
|
||||
try
|
||||
{
|
||||
string? result = reasonValue.Deserialize(WorkflowsJsonUtilities.JsonContext.Default.String);
|
||||
if (result != null)
|
||||
{
|
||||
value = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record BooleanProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<bool>(Key, Question, SchemaTypeSuffix)
|
||||
{
|
||||
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
|
||||
// which is more efficient than looking it up via the options.
|
||||
protected internal override JsonTypeInfo<bool> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.Boolean;
|
||||
|
||||
protected internal override string SchemaType => "boolean";
|
||||
}
|
||||
|
||||
internal sealed record StringProgressLedgerSlot(string Key, string Question, string? SchemaTypeSuffix = null) : ProgressLedgerSlot<string>(Key, Question, SchemaTypeSuffix)
|
||||
{
|
||||
// Since we know the type statically, we can directly return the JsonTypeInfo for string from our JsonContext,
|
||||
// which is more efficient than looking it up via the options.
|
||||
protected internal override JsonTypeInfo<string> GetJsonTypeInfo() => WorkflowsJsonUtilities.JsonContext.Default.String;
|
||||
|
||||
protected internal override string SchemaType => "string";
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
|
||||
string,
|
||||
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.Magentic.MagenticOrchestrator>>;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating Magentic One multi-agent orchestration workflows.
|
||||
///
|
||||
/// Magentic One workflows use an LLM-powered manager to coordinate multiple agents through dynamic task planning, progress tracking,
|
||||
/// and adaptive replanning.The manager creates plans, selects agents, monitors progress, and determines when to replan or complete.
|
||||
///
|
||||
/// The builder provides a fluent API for configuring participants, the manager, optional plan review, checkpointing, and event
|
||||
/// callbacks.
|
||||
///
|
||||
/// Human-in-the-loop Support: Magentic provides specialized HITL mechanisms via:
|
||||
/// - `RequirePlanSignoff` - Review and approve/revise plans before execution
|
||||
/// - Tool approval via `function_approval_request`: Approve individual tool calls on participating agents. Note that tool calls are
|
||||
/// not supported on the ManagerAgent.
|
||||
/// </summary>
|
||||
/// <param name="managerAgent"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public class MagenticWorkflowBuilder(AIAgent managerAgent)
|
||||
{
|
||||
private readonly List<AIAgent> _team = new();
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
|
||||
private int? _maxRounds;
|
||||
private int? _maxResets;
|
||||
private bool _requirePlanSignoff = true;
|
||||
|
||||
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
|
||||
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
this._team.AddRange(agents);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
|
||||
public MagenticWorkflowBuilder WithName(string name)
|
||||
{
|
||||
this._name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
|
||||
public MagenticWorkflowBuilder WithDescription(string description)
|
||||
{
|
||||
this._description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder WithMaxRounds(int? maxRounds = null)
|
||||
{
|
||||
this._maxRounds = maxRounds;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number ofnumber of resets allowed. <see langword="null"/> means unlimited.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder WithMaxResets(int? maxResets = null)
|
||||
{
|
||||
this._maxResets = maxResets;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the maximum number of consecutive rounds without progress before replan (default 3).
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder WithMaxStalls(int maxStalls = TaskLimits.DefaultMaxStallCount)
|
||||
{
|
||||
this._maxStalls = maxStalls;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If <see langword="true"/>, requires human approval of the initial plan or any updates before proceeding. True by default.
|
||||
/// </summary>
|
||||
/// <param name="requirePlanSignoff"></param>
|
||||
/// <returns></returns>
|
||||
public MagenticWorkflowBuilder RequirePlanSignoff(bool requirePlanSignoff = true)
|
||||
{
|
||||
this._requirePlanSignoff = requirePlanSignoff;
|
||||
return this;
|
||||
}
|
||||
|
||||
private WorkflowBuilder ReduceToWorkflowBuilder()
|
||||
{
|
||||
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
|
||||
// workflow in unexpected ways.
|
||||
List<AIAgent> team = [.. this._team];
|
||||
|
||||
ExecutorBinding orchestrator = CreateOrchestratorBinding(managerAgent, team, this.Limits, this._requirePlanSignoff);
|
||||
WorkflowBuilder result = new(orchestrator);
|
||||
|
||||
AIAgentHostOptions options = new()
|
||||
{
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = false
|
||||
};
|
||||
|
||||
List<ExecutorBinding> teamBindings = [];
|
||||
foreach (AIAgent agent in team)
|
||||
{
|
||||
ExecutorBinding binding = agent.BindAsExecutor(options);
|
||||
teamBindings.Add(binding);
|
||||
|
||||
result.AddEdge(binding, orchestrator);
|
||||
}
|
||||
|
||||
result.AddFanOutEdge(orchestrator, teamBindings)
|
||||
.WithOutputFrom(orchestrator);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._name))
|
||||
{
|
||||
result.WithName(this._name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this._description))
|
||||
{
|
||||
result.WithDescription(this._description);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="WorkflowBuilder.Build"/>
|
||||
public Workflow Build() => this.ReduceToWorkflowBuilder().Build();
|
||||
|
||||
private TaskLimits Limits => new(
|
||||
MaxRoundCount: this._maxRounds,
|
||||
MaxResetCount: this._maxResets,
|
||||
MaxStallCount: this._maxStalls);
|
||||
|
||||
private static ExecutorBinding CreateOrchestratorBinding(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
{
|
||||
ExecutorFactoryFunc factory = CreateOrchestratorAsync;
|
||||
return factory.BindExecutor(nameof(MagenticOrchestrator));
|
||||
|
||||
ValueTask<MagenticOrchestrator> CreateOrchestratorAsync(ExecutorConfig<ExecutorOptions> options, string sessionId)
|
||||
{
|
||||
return new(new MagenticOrchestrator(managerAgent, team, limits, requirePlanSignoff));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAIW001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Notifies an AIAgent-hosting executor that it should reset its conversation state, and start a new session, if appropriate.
|
||||
/// Note that for Agent Orchestrations, only Magentic makes use of this functionality.
|
||||
/// </summary>
|
||||
public sealed record ResetChatSignal();
|
||||
@@ -24,7 +24,7 @@ internal static class TurnExtensions
|
||||
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
|
||||
}
|
||||
|
||||
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
internal class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AIAgentHostOptions _options;
|
||||
@@ -40,7 +40,9 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
StringMessageChatRole = ChatRole.User
|
||||
};
|
||||
|
||||
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
|
||||
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
|
||||
|
||||
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: IdFor(agent),
|
||||
s_defaultChatProtocolOptions,
|
||||
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
|
||||
{
|
||||
@@ -67,7 +69,14 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
|
||||
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
|
||||
.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<ResetChatSignal>(this.ResetChat));
|
||||
}
|
||||
|
||||
internal void ResetChat(ResetChatSignal signal, IWorkflowContext context)
|
||||
{
|
||||
this._session = null;
|
||||
this._currentTurnEmitEvents = null;
|
||||
}
|
||||
|
||||
private ValueTask HandleUserInputResponseAsync(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static partial class ChatMessageExtensions
|
||||
{
|
||||
private static void ProcessAIContents(StringBuilder resultBuilder, IEnumerable<AIContent> contents, StreamingToolCallResultPairMatcher? pairMatcher = null)
|
||||
{
|
||||
pairMatcher ??= new();
|
||||
|
||||
foreach (AIContent content in contents)
|
||||
{
|
||||
switch (content)
|
||||
{
|
||||
case TextContent textContent:
|
||||
resultBuilder.AppendLine(textContent.Text);
|
||||
break;
|
||||
|
||||
//case DataContent dataContent:
|
||||
// // We really do not know how to deal with anything other than image data with descriptions, which is not
|
||||
// // a well-defined concept in MEAI (as contrasted with AutoGen's ImageContent type)
|
||||
// break;
|
||||
|
||||
case ErrorContent errorContent:
|
||||
resultBuilder.AppendLine($"[ERROR{(errorContent.ErrorCode != null ? $"(Code={errorContent.ErrorCode})" : string.Empty)}]");
|
||||
resultBuilder.AppendLine(errorContent.Message);
|
||||
|
||||
if (errorContent.Details != null)
|
||||
{
|
||||
resultBuilder.Append("Details:").AppendLine(errorContent.Details);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case FunctionCallContent functionCallContent:
|
||||
pairMatcher.CollectFunctionCall(functionCallContent);
|
||||
break;
|
||||
|
||||
case FunctionResultContent functionResultContent:
|
||||
pairMatcher.TryResolveFunctionCall(functionResultContent, out string? functionName);
|
||||
string result = functionResultContent.Result?.ToString() ?? string.Empty;
|
||||
|
||||
resultBuilder.AppendLine($"[Tool Call '{functionName ?? functionResultContent.CallId}' Result]")
|
||||
.AppendLine(result);
|
||||
|
||||
break;
|
||||
|
||||
case McpServerToolCallContent mstContent:
|
||||
pairMatcher.CollectMcpServerToolCall(mstContent);
|
||||
break;
|
||||
|
||||
case McpServerToolResultContent mstResultContent:
|
||||
if (mstResultContent.Outputs?.Any() is true)
|
||||
{
|
||||
pairMatcher.TryResolveMcpServerToolCall(mstResultContent, out string? mcpServerToolName);
|
||||
resultBuilder.AppendLine($"[Start MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}' Results]");
|
||||
|
||||
ProcessAIContents(resultBuilder, mstResultContent.Outputs!);
|
||||
|
||||
resultBuilder.AppendLine($"[End MCP Server Tool Call '{mcpServerToolName ?? mstResultContent.CallId}']");
|
||||
}
|
||||
|
||||
break;
|
||||
case TextReasoningContent reasoningContent:
|
||||
if (!string.IsNullOrWhiteSpace(reasoningContent.Text))
|
||||
{
|
||||
resultBuilder.Append("[Reasoning] ")
|
||||
.AppendLine(reasoningContent.Text);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case UriContent uriContent:
|
||||
resultBuilder.AppendLine(uriContent.Uri.ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetText(this List<ChatMessage> messages)
|
||||
{
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
StringBuilder builder = new();
|
||||
StreamingToolCallResultPairMatcher pairMatcher = new();
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
ProcessAIContents(builder, message.Contents, pairMatcher);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private const string FencedJsonRegexPattern = @"```(?<lang>[a-z]+)?\s*(?<json>\{[\s\S]*?\})\s*```";
|
||||
#if NET
|
||||
[GeneratedRegex(FencedJsonRegexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)]
|
||||
public static partial Regex FencedJsonRegex();
|
||||
#else
|
||||
public static Regex FencedJsonRegex() => s_fencedJsonRegex;
|
||||
private static readonly Regex s_fencedJsonRegex =
|
||||
new(FencedJsonRegexPattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture);
|
||||
#endif
|
||||
|
||||
internal static JsonElement ExtractJson(string messageText)
|
||||
{
|
||||
Match match = FencedJsonRegex().Match(messageText);
|
||||
if (match.Success)
|
||||
{
|
||||
return JsonElement.Parse(match.Groups["json"].Value);
|
||||
}
|
||||
|
||||
int start = messageText.IndexOf('{'), scanHead = start;
|
||||
int? end = null;
|
||||
|
||||
if (scanHead < 0)
|
||||
{
|
||||
throw new InvalidOperationException("No JSON object found.");
|
||||
}
|
||||
|
||||
int depth = 0;
|
||||
bool inQuotes = false, inEscape = false;
|
||||
for (; scanHead < messageText.Length && end is null; scanHead++)
|
||||
{
|
||||
if (inEscape)
|
||||
{
|
||||
inEscape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (messageText[scanHead])
|
||||
{
|
||||
case '{' when !inQuotes:
|
||||
depth++;
|
||||
break;
|
||||
case '}' when !inQuotes:
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
end = scanHead;
|
||||
}
|
||||
|
||||
break;
|
||||
case '\"':
|
||||
// We already handled inEscape, so we can always flip inQuotes here
|
||||
inQuotes = !inQuotes;
|
||||
break;
|
||||
case '\\':
|
||||
Debug.Assert(!inEscape);
|
||||
inEscape = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (end is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unbalanced JSON braces.");
|
||||
}
|
||||
|
||||
return JsonElement.Parse(messageText.Substring(start, end.Value - start + 1));
|
||||
}
|
||||
|
||||
public static JsonElement ExtractJson(this ChatMessage message) => ExtractJson(message.Text);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal sealed class ExecutorAgentHarness(AIAgent agent, AIAgentUnservicedRequestsCollector collector)
|
||||
{
|
||||
internal const string AgentSessionKey = nameof(AgentSession);
|
||||
private AgentSession? _session;
|
||||
|
||||
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||
this._session ??= await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentResponse response;
|
||||
|
||||
if (emitUpdateEvents)
|
||||
{
|
||||
// Run the agent in streaming mode only when agent run update events are to be emitted.
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentStream = agent.RunStreamingAsync(
|
||||
messages,
|
||||
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
|
||||
{
|
||||
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
|
||||
collector.ProcessAgentResponseUpdate(update);
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
response = updates.ToAgentResponse();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, run the agent in non-streaming mode.
|
||||
response = await agent.RunAsync(messages,
|
||||
await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
collector.ProcessAgentResponse(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async ValueTask<JsonElement?> SerializeSessionAsync(CancellationToken cancellationToken)
|
||||
=> this._session == null
|
||||
? null
|
||||
: await agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async ValueTask DeserializeSessionAsync(JsonElement? serializedSession, CancellationToken cancellationToken)
|
||||
{
|
||||
this._session = serializedSession == null
|
||||
? null
|
||||
: await agent.DeserializeSessionAsync(serializedSession.Value, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
this._session = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static class MagenticConstants
|
||||
{
|
||||
public const string MagenticTaskContextKey = nameof(MagenticTaskContextKey);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal class MagenticManager(AIAgent managerAgent)
|
||||
{
|
||||
private static async ValueTask<ChatMessage> CheckResponseAsync(Task<AgentResponse> responseTask, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
AgentResponse response = await responseTask.ConfigureAwait(false);
|
||||
|
||||
if (response.Messages.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Planner Agent did not return any messages.");
|
||||
}
|
||||
|
||||
if (response.Messages.Count > 1)
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent("Planner Agent returned multiple messages; using the last one."), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response.Messages[response.Messages.Count - 1];
|
||||
}
|
||||
|
||||
private ValueTask<ChatMessage> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken, AgentSession? session = null)
|
||||
=> CheckResponseAsync(managerAgent.RunAsync(messages, session, cancellationToken: cancellationToken), context, cancellationToken);
|
||||
|
||||
public async ValueTask<TaskLedger> UpdatePlanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// If we already have a TaskLedger, we need to update the facts based on the existing factset; otherwise, we use the initial facts construction
|
||||
bool isReplan = taskContext.TaskLedger != null;
|
||||
|
||||
AgentSession localSession = await managerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ChatMessage factsRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerFactsUpdatePrompt() : taskContext.ToTaskLedgerFactsPrompt());
|
||||
ChatMessage updatedFacts = await this.InvokeAgentAsync(
|
||||
messages: [.. taskContext.ChatHistory, factsRequest],
|
||||
context,
|
||||
cancellationToken,
|
||||
localSession)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ChatMessage planRequest = new(ChatRole.User, isReplan ? taskContext.ToTaskLedgerPlanUpdatePrompt() : taskContext.ToTaskLedgerPlanPrompt());
|
||||
ChatMessage updatedPlan = await this.InvokeAgentAsync(
|
||||
// We rely on the AgentSession to maintain the context of the conversation, so we don't include the
|
||||
// history, facts request, or updated facts in the messages list.
|
||||
messages: [planRequest],
|
||||
context,
|
||||
cancellationToken,
|
||||
localSession)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
taskContext.ChatHistory.AddRange([factsRequest, updatedFacts, planRequest, updatedPlan]);
|
||||
|
||||
return new(updatedFacts, updatedPlan);
|
||||
}
|
||||
|
||||
public async ValueTask UpdateProgressLedgerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ChatMessage progressRequest = new(ChatRole.User, taskContext.ToProgressLedgerPrompt());
|
||||
|
||||
ExceptionDispatchInfo? lastException = null;
|
||||
int maxRetryCount = taskContext.TaskLimits.MaxProgressLedgerRetryCount;
|
||||
for (int attempts = 0; attempts < maxRetryCount; attempts++)
|
||||
{
|
||||
ChatMessage progressUpdateMessage = await this.InvokeAgentAsync(
|
||||
messages: [.. taskContext.ChatHistory, progressRequest],
|
||||
context,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
lastException = null;
|
||||
JsonElement stateUpdateJson = progressUpdateMessage.ExtractJson();
|
||||
if (!taskContext.ProgressLedger.TryUpdateState(stateUpdateJson))
|
||||
{
|
||||
throw new InvalidOperationException("Could not answer progress ledger questions with provided JSON.");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
lastException = ExceptionDispatchInfo.Capture(e);
|
||||
|
||||
string warnString = $"Progress ledger JSON parse failed (attempt {attempts}/{maxRetryCount}): {e}";
|
||||
await context.AddEventAsync(new WorkflowWarningEvent(warnString), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (attempts < maxRetryCount)
|
||||
{
|
||||
await Task.Delay(250 * attempts, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastException?.Throw();
|
||||
}
|
||||
|
||||
public async ValueTask<ChatMessage> PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ChatMessage finalAnswerRequest = new(ChatRole.User, taskContext.ToFinalAnswerPrompt());
|
||||
ChatMessage finalAnswer = await this.InvokeAgentAsync([.. taskContext.ChatHistory, finalAnswerRequest], context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new(ChatRole.Assistant, finalAnswer.Text)
|
||||
{
|
||||
AuthorName = finalAnswer.AuthorName ?? nameof(MagenticManager),
|
||||
MessageId = finalAnswer.MessageId ?? Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = finalAnswer.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
RawRepresentation = finalAnswer.RawRepresentation,
|
||||
};
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for Magentic Orchestration Events
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
[JsonDerivedType(typeof(MagenticPlanCreatedEvent))]
|
||||
[JsonDerivedType(typeof(MagenticReplannedEvent))]
|
||||
[JsonDerivedType(typeof(MagenticProgressLedgerUpdatedEvent))]
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public abstract class MagenticOrchestratorEvent(object? data) : WorkflowEvent(data)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the creation of the initial plan
|
||||
/// </summary>
|
||||
/// <param name="fullTaskLeger"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class MagenticPlanCreatedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="ChatMessage"/> containing the initial plan.
|
||||
/// </summary>
|
||||
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the creation of a new plan in response to a stall.
|
||||
/// </summary>
|
||||
/// <param name="fullTaskLeger"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class MagenticReplannedEvent(ChatMessage fullTaskLeger) : MagenticOrchestratorEvent(fullTaskLeger)
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="ChatMessage"/> containing the new plan.
|
||||
/// </summary>
|
||||
public ChatMessage FullTaskLedger { get; } = fullTaskLeger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an update to the <see cref="MagenticProgressLedger"/> when running a coordination round.
|
||||
/// </summary>
|
||||
/// <param name="progressLedger"></param>
|
||||
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
|
||||
public sealed class MagenticProgressLedgerUpdatedEvent(MagenticProgressLedger progressLedger) : MagenticOrchestratorEvent(progressLedger)
|
||||
{
|
||||
/// <summary>
|
||||
/// The new state of the <see cref="MagenticProgressLedger"/>
|
||||
/// </summary>
|
||||
public MagenticProgressLedger ProgressLedger { get; } = progressLedger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Magentic orchestrator that defines the workflow structure.
|
||||
///
|
||||
/// This orchestrator manages the overall Magentic workflow in the following structure:
|
||||
///
|
||||
/// 1. Upon receiving the task(a list of messages), it creates the plan using the manager then runs the inner loop.
|
||||
/// 2. The inner loop is distributed and implementation is decentralized. In the orchestrator, it is responsible for:
|
||||
/// - Creating the progress ledger using the manager.
|
||||
/// - Checking for task completion.
|
||||
/// - Detecting stalling or looping and triggering replanning if needed.
|
||||
/// - Sending requests to participants based on the progress ledger's next speaker.
|
||||
/// - Issue requests for human intervention if enabled and needed.
|
||||
/// 3. The inner loop waits for responses from the selected participant, then continues the loop.
|
||||
/// 4. The orchestrator breaks out of the inner loop when the replanning or final answer conditions are met.
|
||||
/// 5. The outer loop handles replanning and reenters the inner loop.
|
||||
/// </summary>
|
||||
/// <param name="managerAgent"></param>
|
||||
/// <param name="team"></param>
|
||||
/// <param name="limits"></param>
|
||||
/// <param name="requirePlanSignoff"></param>
|
||||
internal class MagenticOrchestrator(AIAgent managerAgent, List<AIAgent> team, TaskLimits limits, bool requirePlanSignoff)
|
||||
: ChatProtocolExecutor(nameof(MagenticOrchestrator), s_options, declareCrossRunShareable: false)
|
||||
{
|
||||
private readonly MagenticManager _manager = new(managerAgent);
|
||||
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
StringMessageChatRole = ChatRole.User,
|
||||
AutoSendTurnToken = false
|
||||
};
|
||||
|
||||
private MagenticTaskContext? _taskContext;
|
||||
private PortBinding? _planReviewPort;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return base.ConfigureProtocol(protocolBuilder).ConfigureRoutes(ConfigureRoutes);
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddPortHandler<MagenticPlanReviewRequest, MagenticPlanReviewResponse>(
|
||||
"RequestPlanReview",
|
||||
this.ProcessPlanReviewAsync,
|
||||
out this._planReviewPort);
|
||||
}
|
||||
|
||||
private ValueTask SubmitPlanReviewRequestAsync(MagenticTaskContext taskContext, IWorkflowContext workflowContext)
|
||||
{
|
||||
MagenticProgressLedger? progressLedger = taskContext.ProgressLedger;
|
||||
if (progressLedger?.IsStarted is not true)
|
||||
{
|
||||
progressLedger = null;
|
||||
}
|
||||
|
||||
MagenticPlanReviewRequest request = new(taskContext.TaskLedger!.CurrentPlan, progressLedger, taskContext.IsStalled);
|
||||
|
||||
return this._planReviewPort!.PostRequestAsync(request);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessPlanReviewAsync(MagenticPlanReviewResponse response, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
/*
|
||||
Handle the human response to the plan review request.
|
||||
|
||||
Logic:
|
||||
There are code paths which will trigger a plan review request to the human:
|
||||
- Initial plan creation if `require_plan_signoff` is True.
|
||||
- Potentially during the inner loop if stalling is detected (resetting and replanning).
|
||||
|
||||
The human can either approve the plan or request revisions with comments.
|
||||
- If approved, proceed to run the outer loop, which simply adds the task ledger
|
||||
to the conversation and enters the inner loop.
|
||||
- If revision requested, append the review comments to the chat history,
|
||||
trigger replanning via the manager, emit a REPLANNED event, then run the outer loop.
|
||||
|
||||
*/
|
||||
if (this._taskContext == null || this._taskContext.TaskLedger == null)
|
||||
{
|
||||
throw new InvalidOperationException("Magentic Orchestration was not initialized correctly.");
|
||||
}
|
||||
|
||||
if (this._taskContext.IsTerminated)
|
||||
{
|
||||
throw new InvalidOperationException("Magentic Orchestration has already been terminated and cannot process new messages. Please start a new session.");
|
||||
}
|
||||
|
||||
if (response.IsApproved)
|
||||
{
|
||||
await this.DelegateToTeamAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._taskContext.ChatHistory.AddRange(response.Review);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UpdatePlanAndDelegateAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
bool isReplan = taskContext.TaskLedger != null;
|
||||
|
||||
taskContext.TaskLedger = await this._manager.UpdatePlanAsync(taskContext, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
this._fullTaskLedgerMessage = new(ChatRole.User, taskContext.ToTaskLedgerFullPrompt());
|
||||
taskContext.ChatHistory.Add(this._fullTaskLedgerMessage);
|
||||
|
||||
await context.AddEventAsync(isReplan
|
||||
? new MagenticReplannedEvent(this._fullTaskLedgerMessage)
|
||||
: new MagenticPlanCreatedEvent(this._fullTaskLedgerMessage), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (requirePlanSignoff)
|
||||
{
|
||||
await this.SubmitPlanReviewRequestAsync(taskContext, context).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.DelegateToTeamAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First Turn: Initialize the task context and send the initial messages to the planner agent
|
||||
this._taskContext ??= new(messages, team, limits, emitEvents, []);
|
||||
await this.UpdatePlanAndDelegateAsync(this._taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ChatMessage? _fullTaskLedgerMessage;
|
||||
private ValueTask DelegateToTeamAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.RunCoordinationRoundAsync(taskContext, context, cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask RunCoordinationRoundAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
(bool hitRoundLimit, bool hitResetLimit) = taskContext.CheckLimits();
|
||||
|
||||
if (hitRoundLimit || hitResetLimit)
|
||||
{
|
||||
string limitType = hitRoundLimit ? "round" : "reset";
|
||||
|
||||
List<ChatMessage> messages = [new(ChatRole.Assistant, $"Task execution stopped due to hitting the maximum {limitType} count limit.")];
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
taskContext.IsTerminated = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
taskContext.TaskCounters.RoundCount++;
|
||||
|
||||
// Update the Progress Ledger
|
||||
try
|
||||
{
|
||||
await this._manager.UpdateProgressLedgerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await context.AddEventAsync(new MagenticProgressLedgerUpdatedEvent(taskContext.ProgressLedger), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
// Retry on exception to max retry count, unless it is OperationCancelledException - in that case exit the loop right away
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent($"Magentic Orchestrator: Progress ledger creation failed, triggering reset: {ex}"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and handle finish condition
|
||||
if (taskContext.ProgressLedger.IsRequestSatisfied)
|
||||
{
|
||||
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check and handle stalls
|
||||
if (taskContext.ProgressLedger.IsInLoop || !taskContext.ProgressLedger.IsProgressBeingMade)
|
||||
{
|
||||
taskContext.TaskCounters.StallCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
taskContext.TaskCounters.StallCount = Math.Max(0, taskContext.TaskCounters.StallCount - 1);
|
||||
}
|
||||
|
||||
if (taskContext.IsStalled)
|
||||
{
|
||||
await this.ResetAndReplanAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare to delegate to the next speaker
|
||||
string nextSpeaker = taskContext.ProgressLedger.NextSpeaker;
|
||||
if (string.IsNullOrEmpty(nextSpeaker))
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent("Next speaker answer empty; selecting first participant as fallback"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
nextSpeaker = team.First().Name!;
|
||||
}
|
||||
|
||||
AIAgent? nextAgent = team.FirstOrDefault(agent => agent.Name == nextSpeaker);
|
||||
if (nextAgent == null)
|
||||
{
|
||||
await context.AddEventAsync(new WorkflowWarningEvent($"Invalid next speaker: {nextSpeaker}"), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await this.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taskContext.ProgressLedger.InstructionOrQuestion))
|
||||
{
|
||||
ChatMessage instruction = new(ChatRole.Assistant, taskContext.ProgressLedger.InstructionOrQuestion);
|
||||
taskContext.ChatHistory.Add(instruction);
|
||||
|
||||
await context.SendMessageAsync(instruction, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string nextExecutorId = AIAgentHostExecutor.IdFor(nextAgent);
|
||||
await context.SendMessageAsync(new TurnToken(taskContext.EmitUpdateEvents), nextExecutorId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ResetAndReplanAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
taskContext.Reset();
|
||||
await context.SendMessageAsync(new ResetChatSignal(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.UpdatePlanAndDelegateAsync(taskContext, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PrepareFinalAnswerAsync(MagenticTaskContext taskContext, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ChatMessage> messages = [await this._manager.PrepareFinalAnswerAsync(taskContext, context, cancellationToken).ConfigureAwait(false)];
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
taskContext.IsTerminated = true;
|
||||
}
|
||||
|
||||
private const string CurrentTurnEmitUpdateEventsKey = nameof(CurrentTurnEmitUpdateEventsKey);
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Task contextStateTask = this._taskContext == null
|
||||
? Task.CompletedTask
|
||||
: context.QueueStateUpdateAsync(MagenticConstants.MagenticTaskContextKey,
|
||||
this._taskContext.ExportState(),
|
||||
cancellationToken: cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
await Task.WhenAll(base.OnCheckpointingAsync(context, cancellationToken).AsTask(),
|
||||
contextStateTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.WhenAll(base.OnCheckpointRestoredAsync(context, cancellationToken).AsTask(), LoadContextStateAsync())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
async Task LoadContextStateAsync()
|
||||
{
|
||||
MagenticTaskState? state = await context.ReadStateAsync<MagenticTaskState>(MagenticConstants.MagenticTaskContextKey, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (state != null)
|
||||
{
|
||||
this._taskContext = new MagenticTaskContext(state, team, limits, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal record TaskLimits(int MaxStallCount = TaskLimits.DefaultMaxStallCount,
|
||||
int? MaxRoundCount = null,
|
||||
int? MaxResetCount = null,
|
||||
int MaxProgressLedgerRetryCount = TaskLimits.DefaultMaxProgressLedgerRetryCount)
|
||||
{
|
||||
public const int DefaultMaxStallCount = 3;
|
||||
public const int DefaultMaxProgressLedgerRetryCount = 3;
|
||||
}
|
||||
|
||||
internal record TaskLedger(ChatMessage CurrentFacts, ChatMessage CurrentPlan);
|
||||
|
||||
internal class TaskCounters
|
||||
{
|
||||
public int RoundCount { get; set; }
|
||||
public int StallCount { get; set; }
|
||||
public int ResetCount { get; set; }
|
||||
}
|
||||
|
||||
internal record MagenticTaskState(List<ChatMessage> TaskDefinition, List<ChatMessage> ChatHistory, TaskLedger? TaskLedger, JsonElement? ProgressLedgerState, TaskCounters Counters, bool Terminated, bool? EmitUpdateEvents)
|
||||
{
|
||||
}
|
||||
|
||||
internal class MagenticTaskContext(List<ChatMessage> taskDefinition, List<AIAgent> team, TaskLimits limits, bool? emitUpdateEvents, IEnumerable<ProgressLedgerSlot> additionalProgressQuestions)
|
||||
{
|
||||
internal MagenticTaskContext(MagenticTaskState state, List<AIAgent> team, TaskLimits limits, IEnumerable<ProgressLedgerSlot> additionalProgressQuestions)
|
||||
: this(state.TaskDefinition, team, limits, state.EmitUpdateEvents, additionalProgressQuestions)
|
||||
{
|
||||
this.TaskLedger = state.TaskLedger;
|
||||
this.TaskCounters = state.Counters;
|
||||
this.ChatHistory = state.ChatHistory;
|
||||
this.IsTerminated = state.Terminated;
|
||||
|
||||
if (state.ProgressLedgerState.HasValue && !this.ProgressLedger.TryUpdateState(state.ProgressLedgerState.Value))
|
||||
{
|
||||
throw new InvalidOperationException("Could not load progress ledger state value");
|
||||
}
|
||||
}
|
||||
|
||||
public string Task { get; } = taskDefinition.GetText();
|
||||
|
||||
public string TeamDescription { get; } = GetTeamDescription(team);
|
||||
|
||||
public List<ChatMessage> ChatHistory { get; internal set; } = new();
|
||||
|
||||
public TaskLedger? TaskLedger { get; internal set; }
|
||||
|
||||
public TaskLimits TaskLimits => limits;
|
||||
|
||||
public bool IsTerminated { get; internal set; }
|
||||
|
||||
public bool IsStalled => this.TaskCounters.StallCount >= this.TaskLimits.MaxStallCount;
|
||||
|
||||
public (bool HitRoundLimit, bool HitResetLimit) CheckLimits()
|
||||
{
|
||||
return (this.TaskLimits.MaxRoundCount.HasValue && this.TaskLimits.MaxRoundCount.Value <= this.TaskCounters.RoundCount,
|
||||
this.TaskLimits.MaxResetCount.HasValue && this.TaskLimits.MaxResetCount.Value <= this.TaskCounters.ResetCount);
|
||||
}
|
||||
|
||||
public TaskCounters TaskCounters { get; internal set; } = new();
|
||||
|
||||
public MagenticProgressLedger ProgressLedger { get; } = new(GetTeamNames(team), additionalProgressQuestions);
|
||||
public bool? EmitUpdateEvents => emitUpdateEvents;
|
||||
|
||||
public static string GetTeamDescription(IEnumerable<AIAgent> team)
|
||||
{
|
||||
return string.Join("\n", team.Select(agent => $"- {agent.Name}: {agent.Description}"));
|
||||
}
|
||||
|
||||
public static string GetTeamNames(IEnumerable<AIAgent> team)
|
||||
{
|
||||
return string.Join(", ", team.Select(agent => agent.Name));
|
||||
}
|
||||
|
||||
public MagenticTaskState ExportState()
|
||||
{
|
||||
return new(taskDefinition, this.ChatHistory, this.TaskLedger, this.ProgressLedger.State, this.TaskCounters, this.IsTerminated, this.EmitUpdateEvents);
|
||||
}
|
||||
|
||||
internal void Reset()
|
||||
{
|
||||
this.ChatHistory.Clear();
|
||||
this.TaskCounters.ResetCount++;
|
||||
this.TaskCounters.StallCount = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal static class PromptTemplateExtensions
|
||||
{
|
||||
public static string ToTaskLedgerFactsPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Below I will present you a request.
|
||||
|
||||
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
|
||||
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
|
||||
a deep well to draw from.
|
||||
|
||||
Here is the request:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
Here is the pre-survey:
|
||||
|
||||
1. Please list any specific facts or figures that are GIVEN in the request itself.It is possible that
|
||||
there are none.
|
||||
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
|
||||
In some cases, authoritative sources are mentioned in the request itself.
|
||||
3. Please list any facts that may need to be derived(e.g., via logical deduction, simulation, or computation)
|
||||
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
|
||||
|
||||
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
|
||||
Your answer should use headings:
|
||||
|
||||
1. GIVEN OR VERIFIED FACTS
|
||||
2. FACTS TO LOOK UP
|
||||
3. FACTS TO DERIVE
|
||||
4. EDUCATED GUESSES
|
||||
|
||||
DO NOT include any other headings or sections in your response.DO NOT list next steps or plans until asked to do so.
|
||||
""";
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerFactsUpdatePrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
As a reminder, we are working to solve the following task:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
It is clear we are not making as much progress as we would like, but we may have learned something new.
|
||||
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
|
||||
|
||||
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
|
||||
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
|
||||
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
|
||||
one educated guess or hunch, and explain your reasoning.
|
||||
|
||||
Here is the old fact sheet:
|
||||
|
||||
{taskContext.TaskLedger?.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
|
||||
""";
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerPlanPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Fantastic. To address this request we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
|
||||
original request. Remember, there is no requirement to involve all team members. A team member's particular expertise
|
||||
may not be needed for this task.
|
||||
""";
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerPlanUpdatePrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
Please briefly explain what went wrong on this last run
|
||||
(the root cause of the failure), and then come up with a new plan that takes steps and includes hints to overcome prior
|
||||
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, expressed in
|
||||
bullet-point form, and consider the following team composition:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
""";
|
||||
}
|
||||
|
||||
public static string ToTaskLedgerFullPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
We are working to address the following user request:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
|
||||
To answer this request we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
|
||||
Here is an initial fact sheet to consider:
|
||||
|
||||
{taskContext.TaskLedger!.CurrentFacts ?? new(ChatRole.Assistant, string.Empty)}
|
||||
|
||||
|
||||
Here is the plan to follow as best as possible:
|
||||
|
||||
{taskContext.TaskLedger!.CurrentPlan}
|
||||
""";
|
||||
}
|
||||
|
||||
public static string ToProgressLedgerPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
(string questions, string schema) = taskContext.ProgressLedger.FormatQuestions();
|
||||
|
||||
return $"""
|
||||
Recall we are working on the following request:
|
||||
|
||||
{taskContext.Task}
|
||||
|
||||
And we have assembled the following team:
|
||||
|
||||
{taskContext.TeamDescription}
|
||||
|
||||
To make progress on the request, please answer the following questions, including necessary reasoning:
|
||||
|
||||
{questions}
|
||||
|
||||
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
|
||||
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
|
||||
|
||||
{schema}
|
||||
""";
|
||||
}
|
||||
|
||||
public static string ToFinalAnswerPrompt(this MagenticTaskContext taskContext)
|
||||
{
|
||||
return $"""
|
||||
We are working on the following task:
|
||||
{taskContext.Task}
|
||||
|
||||
We have completed the task.
|
||||
|
||||
The above messages contain the conversation that took place to complete the task.
|
||||
|
||||
Based on the information gathered, provide the final answer to the original request.
|
||||
The answer should be phrased as if you were speaking to the user.
|
||||
""";
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
internal sealed class StreamingToolCallResultPairMatcher
|
||||
{
|
||||
private enum CallType
|
||||
{
|
||||
Function,
|
||||
McpServerTool
|
||||
}
|
||||
|
||||
private record CallSummaryKey(CallType Type, string CallId);
|
||||
|
||||
private struct ToolCallSummary(CallType callType, string callId, string name)
|
||||
{
|
||||
public CallType CallType => callType;
|
||||
|
||||
public string? CallId => callId;
|
||||
|
||||
public string Name => name;
|
||||
}
|
||||
|
||||
private readonly Dictionary<CallSummaryKey, ToolCallSummary> _callSummaries = new();
|
||||
|
||||
private void Collect(CallType callType, string callId, string name, string callContentTypeName, string resultContentTypeName)
|
||||
{
|
||||
CallSummaryKey key = new(callType, callId);
|
||||
if (this._callSummaries.ContainsKey(key))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate {callContentTypeName} with CallId '{callId}' without corresponding {resultContentTypeName}.");
|
||||
}
|
||||
|
||||
this._callSummaries[key] = new ToolCallSummary(callType, callId, name);
|
||||
}
|
||||
|
||||
public void CollectFunctionCall(FunctionCallContent callContent)
|
||||
{
|
||||
const string FunctionCallContentTypeName = nameof(FunctionCallContent);
|
||||
const string FunctionResultContentTypeName = nameof(FunctionResultContent);
|
||||
|
||||
this.Collect(CallType.Function, callContent.CallId, callContent.Name, FunctionCallContentTypeName, FunctionResultContentTypeName);
|
||||
}
|
||||
|
||||
public void CollectMcpServerToolCall(McpServerToolCallContent callContent)
|
||||
{
|
||||
const string McpServerToolCallContentTypeName = nameof(McpServerToolCallContent);
|
||||
const string McpServerToolResultContentTypeName = nameof(McpServerToolResultContent);
|
||||
|
||||
this.Collect(CallType.McpServerTool, callContent.CallId, callContent.Name, McpServerToolCallContentTypeName, McpServerToolResultContentTypeName);
|
||||
}
|
||||
|
||||
private bool TryResolve(CallType callType, string callId, [NotNullWhen(true)] out string? name)
|
||||
{
|
||||
CallSummaryKey key = new(callType, callId);
|
||||
|
||||
bool hasMatchingCall = this._callSummaries.TryGetValue(key, out ToolCallSummary callSummary);
|
||||
if (hasMatchingCall)
|
||||
{
|
||||
this._callSummaries.Remove(key);
|
||||
}
|
||||
|
||||
name = hasMatchingCall ? callSummary.Name : null;
|
||||
return hasMatchingCall;
|
||||
}
|
||||
|
||||
public bool TryResolveFunctionCall(FunctionResultContent resultContent, [NotNullWhen(true)] out string? name)
|
||||
=> this.TryResolve(CallType.Function, resultContent.CallId, out name);
|
||||
|
||||
public bool TryResolveMcpServerToolCall(McpServerToolResultContent resultContent, [NotNullWhen(true)] out string? name)
|
||||
=> this.TryResolve(CallType.McpServerTool, resultContent.CallId, out name);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
@@ -14,6 +15,8 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
[JsonDerivedType(typeof(WorkflowWarningEvent))]
|
||||
[JsonDerivedType(typeof(WorkflowOutputEvent))]
|
||||
[JsonDerivedType(typeof(RequestInfoEvent))]
|
||||
[JsonDerivedType(typeof(MagenticOrchestratorEvent))]
|
||||
|
||||
public class WorkflowEvent(object? data = null)
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized.Magentic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
@@ -97,6 +98,10 @@ internal static partial class WorkflowsJsonUtilities
|
||||
[JsonSerializable(typeof(AIAgentHostState))]
|
||||
[JsonSerializable(typeof(HandoffSharedState))]
|
||||
[JsonSerializable(typeof(HandoffAgentHostState))]
|
||||
[JsonSerializable(typeof(MagenticPlanReviewRequest))]
|
||||
[JsonSerializable(typeof(MagenticPlanReviewResponse))]
|
||||
[JsonSerializable(typeof(MagenticTaskState))]
|
||||
[JsonSerializable(typeof(ResetChatSignal))]
|
||||
|
||||
// Event Types
|
||||
//[JsonSerializable(typeof(WorkflowEvent))]
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+16
-4
@@ -17,9 +17,6 @@ namespace AnthropicChatCompletion.IntegrationTests;
|
||||
|
||||
public class AnthropicChatCompletionFixture : IChatClientAgentFixture
|
||||
{
|
||||
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
|
||||
internal const string SkipReason = "Integrations tests for local execution only";
|
||||
|
||||
private readonly bool _useReasoningModel;
|
||||
private readonly bool _useBeta;
|
||||
|
||||
@@ -105,7 +102,22 @@ public class AnthropicChatCompletionFixture : IChatClientAgentFixture
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
// Temporarily disabled: Anthropic SDK has a binary incompatibility with the current
|
||||
// Microsoft.Extensions.AI version (WebSearchToolResultContent.Results method not found).
|
||||
// See: https://github.com/microsoft/agent-framework/pull/5515
|
||||
Assert.Skip("Anthropic integration tests temporarily disabled due to SDK incompatibility with Microsoft.Extensions.AI");
|
||||
|
||||
try
|
||||
{
|
||||
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey);
|
||||
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
|
||||
_ = TestConfiguration.GetRequiredValue(TestSettings.AnthropicReasoningModelName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
|
||||
}
|
||||
|
||||
this._agent = await this.CreateChatClientAgentAsync();
|
||||
}
|
||||
|
||||
|
||||
+28
-12
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AgentConformance.IntegrationTests.Support;
|
||||
using Anthropic;
|
||||
@@ -17,19 +18,28 @@ namespace AnthropicChatCompletion.IntegrationTests;
|
||||
/// Integration tests for Anthropic Skills functionality.
|
||||
/// These tests are designed to be run locally with a valid Anthropic API key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Temporarily disabled due to Anthropic SDK binary incompatibility with
|
||||
/// the current Microsoft.Extensions.AI version (WebSearchToolResultContent.Results).
|
||||
/// </remarks>
|
||||
[Trait("Category", "IntegrationDisabled")]
|
||||
public sealed class AnthropicSkillsIntegrationTests
|
||||
{
|
||||
// All tests for Anthropic are intended to be ran locally as the CI pipeline for Anthropic is not setup.
|
||||
private const string SkipReason = "Integrations tests for local execution only";
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAgentWithPptxSkillAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
|
||||
// Arrange
|
||||
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
|
||||
string model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
|
||||
AnthropicClient? anthropicClient;
|
||||
string? model;
|
||||
try
|
||||
{
|
||||
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
|
||||
model = TestConfiguration.GetRequiredValue(TestSettings.AnthropicChatModelName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
BetaSkillParams pptxSkill = new()
|
||||
{
|
||||
@@ -56,10 +66,16 @@ public sealed class AnthropicSkillsIntegrationTests
|
||||
[Fact]
|
||||
public async Task ListAnthropicManagedSkillsAsync()
|
||||
{
|
||||
Assert.SkipWhen(SkipReason is not null, SkipReason ?? string.Empty);
|
||||
|
||||
// Arrange
|
||||
AnthropicClient anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
|
||||
AnthropicClient? anthropicClient;
|
||||
try
|
||||
{
|
||||
anthropicClient = new() { ApiKey = TestConfiguration.GetRequiredValue(TestSettings.AnthropicApiKey) };
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Assert.Skip("Anthropic configuration could not be loaded. Error:" + ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Act
|
||||
SkillListPage skills = await anthropicClient.Beta.Skills.List(
|
||||
|
||||
+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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user